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
+1
View File
@@ -67,3 +67,4 @@ ds-bundle/
packages/ui/.ds-compiled.css
packages/ui/.ds-tw-raw.css
packages/ui/dist/
i18n.cache
+1 -1
View File
@@ -77,7 +77,7 @@
"@types/node": "^26.1.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.17",
"@typescript/native-preview": "7.0.0-dev.20260704.1",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"tsdown": "^0.22.3",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
+658 -21
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+658 -21
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+658 -21
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+658 -21
View File
File diff suppressed because it is too large Load Diff
+658 -21
View File
File diff suppressed because it is too large Load Diff
+658 -21
View File
File diff suppressed because it is too large Load Diff
+658 -21
View File
File diff suppressed because it is too large Load Diff
+658 -21
View File
File diff suppressed because it is too large Load Diff
+658 -21
View File
File diff suppressed because it is too large Load Diff
+653 -15
View File
File diff suppressed because it is too large Load Diff
+658 -21
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+659 -22
View File
File diff suppressed because it is too large Load Diff
+658 -21
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+670 -32
View File
File diff suppressed because it is too large Load Diff
+668 -30
View File
File diff suppressed because it is too large Load Diff
+670 -32
View File
File diff suppressed because it is too large Load Diff
+670 -32
View File
File diff suppressed because it is too large Load Diff
+670 -32
View File
File diff suppressed because it is too large Load Diff
+670 -32
View File
File diff suppressed because it is too large Load Diff
+670 -32
View File
File diff suppressed because it is too large Load Diff
+670 -32
View File
File diff suppressed because it is too large Load Diff
+670 -32
View File
File diff suppressed because it is too large Load Diff
+661 -24
View File
File diff suppressed because it is too large Load Diff
+661 -24
View File
File diff suppressed because it is too large Load Diff
+661 -24
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+661 -24
View File
File diff suppressed because it is too large Load Diff
+661 -24
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+660 -23
View File
File diff suppressed because it is too large Load Diff
+652 -14
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -92,12 +92,16 @@
"@lingui/vite-plugin": "^6.4.0",
"@reactive-resume/config": "workspace:*",
"@rolldown/plugin-babel": "^0.2.3",
"@tanstack/devtools-vite": "^0.8.1",
"@tanstack/react-devtools": "^0.10.8",
"@tanstack/react-query-devtools": "^5.101.2",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.19",
"@types/babel__core": "^7.20.5",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260704.1",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"@vitejs/plugin-react": "^6.0.3",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-react-compiler": "^1.0.0",
@@ -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", () => {
+1 -7
View File
@@ -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;
}
+13 -13
View File
@@ -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}
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { detectJsonImportType } from "./import";
import { detectJsonImportType } from "./import.utils";
describe("detectJsonImportType", () => {
it("detects JSON Resume by a top-level basics without Reactive Resume sections/metadata", () => {
+2 -21
View File
@@ -1,5 +1,6 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { DialogProps } from "../store";
import type { ImportType } from "./import.utils";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react";
@@ -31,6 +32,7 @@ import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
import { useAppForm } from "@/libs/tanstack-form";
import { useDialogStore } from "../store";
import { detectJsonImportType } from "./import.utils";
const formSchema = z.discriminatedUnion("type", [
z.object({
@@ -72,9 +74,6 @@ const formSchema = z.discriminatedUnion("type", [
}),
]);
type FormValues = z.infer<typeof formSchema>;
type ImportType = FormValues["type"];
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -120,24 +119,6 @@ async function detectImportType(file: File): Promise<ImportType> {
return "";
}
export function detectJsonImportType(parsed: unknown): ImportType {
if (!parsed || typeof parsed !== "object") return "";
const data = parsed as Record<string, unknown>;
// JSON Resume standard: top-level `basics`, without Reactive Resume's `sections`/`metadata`.
if ("basics" in data && !("sections" in data) && !("metadata" in data)) return "json-resume-json";
// Reactive Resume exports carry `sections` + `metadata`; the current schema's metadata has a `page` key,
// the legacy v4 schema does not. Best-effort guess — the user can override the type below.
if ("sections" in data || "metadata" in data) {
const metadata = data.metadata as Record<string, unknown> | undefined;
if (metadata && !("page" in metadata)) return "reactive-resume-v4-json";
return "reactive-resume-json";
}
return "";
}
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const navigate = useNavigate();
const closeDialog = useDialogStore((state) => state.closeDialog);
@@ -0,0 +1,19 @@
export type ImportType = "" | "pdf" | "docx" | "reactive-resume-json" | "reactive-resume-v4-json" | "json-resume-json";
export function detectJsonImportType(parsed: unknown): ImportType {
if (!parsed || typeof parsed !== "object") return "";
const data = parsed as Record<string, unknown>;
// JSON Resume standard: top-level `basics`, without Reactive Resume's `sections`/`metadata`.
if ("basics" in data && !("sections" in data) && !("metadata" in data)) return "json-resume-json";
// Reactive Resume exports carry `sections` + `metadata`; the current schema's metadata has a `page` key,
// the legacy v4 schema does not. Best-effort guess — the user can override the type below.
if ("sections" in data || "metadata" in data) {
const metadata = data.metadata as Record<string, unknown> | undefined;
if (metadata && !("page" in metadata)) return "reactive-resume-v4-json";
return "reactive-resume-json";
}
return "";
}
@@ -0,0 +1,141 @@
import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ArchiveIcon,
ArrowRightIcon,
DotsThreeVerticalIcon,
PencilSimpleIcon,
TrashIcon,
TrayArrowUpIcon,
} from "@phosphor-icons/react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { cn } from "@reactive-resume/utils/style";
import { useConfirm } from "@/hooks/use-confirm";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
type Props = {
application: Application;
onEdit: (application: Application) => void;
// Whether the trigger should only appear on hover of the parent (cards). Rows keep it visible.
showOnHover?: boolean;
className?: string;
};
// Stop pointer/click from reaching the card (which would start a drag or open the detail panel).
// React portals bubble synthetic events through the React tree, so a menu-item click would
// otherwise reach the card's onClick even though the menu is portaled in the DOM.
const stop = (event: React.SyntheticEvent) => event.stopPropagation();
// Shared kebab menu for board cards and table rows: edit, move stage, archive, delete.
export function ApplicationActionsMenu({ application, onEdit, showOnHover, className }: Props) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() });
};
const update = useMutation(
orpc.applications.update.mutationOptions({
onSuccess: invalidate,
onError: () => toast.error(t`Something went wrong. Please try again.`),
}),
);
const remove = useMutation(
orpc.applications.delete.mutationOptions({
onSuccess: () => {
invalidate();
toast.success(t`Application deleted.`);
},
onError: () => toast.error(t`Couldn't delete the application.`),
}),
);
const onDelete = async () => {
const confirmed = await confirm(t`Delete this application?`, {
description: t`"${application.role} · ${application.company}" and its full timeline will be permanently deleted. This can't be undone.`,
confirmText: t`Delete`,
});
if (confirmed) remove.mutate({ id: application.id });
};
return (
<div className={cn("shrink-0", className)}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon-sm"
variant="ghost"
aria-label={t`Application actions`}
onClick={stop}
onPointerDown={stop}
className={cn(
"size-6 text-muted-foreground",
showOnHover &&
"opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100 data-[popup-open]:opacity-100",
)}
>
<DotsThreeVerticalIcon />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-44" onClick={stop}>
<DropdownMenuItem onClick={() => onEdit(application)}>
<PencilSimpleIcon />
<Trans>Edit</Trans>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ArrowRightIcon />
<Trans>Move to</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{STAGES.map((stage) => (
<DropdownMenuItem
key={stage.value}
disabled={stage.value === application.status}
onClick={() => update.mutate({ id: application.id, status: stage.value })}
>
<span className="size-2 rounded-sm" style={{ background: stage.color }} />
{stage.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem onClick={() => update.mutate({ id: application.id, archived: !application.archived })}>
{application.archived ? <TrayArrowUpIcon /> : <ArchiveIcon />}
{application.archived ? <Trans>Unarchive</Trans> : <Trans>Archive</Trans>}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<TrashIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,269 @@
import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ArrowsClockwiseIcon,
CaretRightIcon,
CopyIcon,
EnvelopeSimpleIcon,
MagicWandIcon,
PaperPlaneTiltIcon,
SparkleIcon,
SpinnerGapIcon,
} from "@phosphor-icons/react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
// Score bands drive the ring color and the label — a job-fit gauge, not a generic percentage.
function band(score: number) {
if (score >= 75) return { color: "#10b981", label: t`Strong fit` };
if (score >= 50) return { color: "#f59e0b", label: t`Worth a shot` };
return { color: "#f43f5e", label: t`A stretch` };
}
function aiGaps(app: Application): string[] {
const meta = app.aiMetadata as { matchScore?: { gaps?: unknown } } | null | undefined;
const gaps = meta?.matchScore?.gaps;
return Array.isArray(gaps) ? gaps.filter((gap): gap is string => typeof gap === "string") : [];
}
// The signature element: a circular resume-fit gauge that animates to the score.
function FitRing({ score }: { score: number }) {
const size = 60;
const stroke = 5;
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const { color } = band(score);
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="shrink-0 -rotate-90" aria-hidden="true">
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
className="text-muted"
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={c - (Math.max(0, Math.min(100, score)) / 100) * c}
className="transition-[stroke-dashoffset] duration-700 ease-out motion-reduce:transition-none"
/>
</svg>
);
}
type ActionRowProps = {
icon: React.ReactNode;
title: React.ReactNode;
description: React.ReactNode;
disabled?: boolean;
pending?: boolean;
onClick: () => void;
};
function ActionRow({ icon, title, description, disabled, pending, onClick }: ActionRowProps) {
return (
<button
type="button"
disabled={disabled || pending}
onClick={onClick}
className={cn(
"group/row flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors",
"hover:bg-primary/10 disabled:pointer-events-none disabled:opacity-45",
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
{pending ? <SpinnerGapIcon className="animate-spin" /> : icon}
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium text-sm">{title}</span>
<span className="block truncate text-muted-foreground text-xs">{description}</span>
</span>
<CaretRightIcon className="shrink-0 text-muted-foreground/50 transition-transform group-hover/row:translate-x-0.5" />
</button>
);
}
type Props = { application: Application };
export function ApplicationAiCopilot({ application }: Props) {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<{ kind: string; text: string } | null>(null);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
void queryClient.invalidateQueries({
queryKey: orpc.applications.getById.queryKey({ input: { id: application.id } }),
});
};
const matchScore = useMutation(
orpc.applications.ai.matchScore.mutationOptions({
onSuccess: invalidate,
onError: (error) => toast.error(error.message || t`Match scoring failed.`),
}),
);
const tailorResume = useMutation(
orpc.applications.ai.tailorResume.mutationOptions({
onSuccess: (result) => {
invalidate();
toast.success(t`Created "${result.name}" and linked it to this application.`);
},
onError: (error) => toast.error(error.message || t`Tailoring failed.`),
}),
);
const draftMessage = useMutation(
orpc.applications.ai.draftMessage.mutationOptions({
onSuccess: (result, variables) => setDraft({ kind: variables.kind, text: result.text }),
onError: (error) => toast.error(error.message || t`Drafting failed.`),
}),
);
const pending = matchScore.isPending || tailorResume.isPending || draftMessage.isPending;
const canScore = !!application.resumeId && !!application.jobDescription;
const score = application.matchScore;
const gaps = aiGaps(application);
return (
<section className="overflow-hidden rounded-xl border border-primary/15 bg-primary/[0.04]">
<header className="flex items-center gap-2 px-3.5 pt-3">
<span className="flex size-5 items-center justify-center rounded-md bg-primary text-primary-foreground">
<SparkleIcon weight="fill" className="size-3" />
</span>
<span className="font-medium text-sm">
<Trans>Application Copilot</Trans>
</span>
</header>
{/* Fit gauge — the signature. Adapts to whether prerequisites are met and whether a score exists. */}
<div className="px-3.5 py-3">
{!canScore ? (
<p className="rounded-lg bg-muted/50 p-2.5 text-muted-foreground text-xs">
<Trans>Link a resume and add a job description (Edit) to score your fit and tailor a copy.</Trans>
</p>
) : score == null ? (
<button
type="button"
disabled={pending}
onClick={() => matchScore.mutate({ id: application.id })}
className="flex w-full items-center gap-3 rounded-lg border border-primary/20 border-dashed p-2.5 text-left transition-colors hover:bg-primary/10 disabled:opacity-60"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
{matchScore.isPending ? (
<SpinnerGapIcon className="size-5 animate-spin" />
) : (
<SparkleIcon className="size-5" />
)}
</span>
<span>
<span className="block font-medium text-sm">
{matchScore.isPending ? <Trans>Scoring your fit</Trans> : <Trans>Score my fit</Trans>}
</span>
<span className="block text-muted-foreground text-xs">
<Trans>See how this resume matches the posting</Trans>
</span>
</span>
</button>
) : (
<div className="flex items-center gap-3.5">
<div className="relative flex items-center justify-center">
<FitRing score={score} />
<span className="absolute font-semibold text-base tabular-nums">{score}</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm" style={{ color: band(score).color }}>
{band(score).label}
</span>
<button
type="button"
disabled={pending}
onClick={() => matchScore.mutate({ id: application.id })}
className="text-muted-foreground text-xs hover:text-foreground disabled:opacity-50"
title={t`Re-score`}
>
<ArrowsClockwiseIcon className={cn("size-3.5", matchScore.isPending && "animate-spin")} />
</button>
</div>
{gaps.length > 0 && (
<p className="mt-0.5 line-clamp-2 text-muted-foreground text-xs">
<Trans>Gaps:</Trans> {gaps.slice(0, 3).join(" · ")}
</p>
)}
</div>
</div>
)}
</div>
<div className="border-primary/10 border-t px-2 py-2">
<ActionRow
icon={<MagicWandIcon />}
title={<Trans>Tailor my resume</Trans>}
description={t`Create a copy tuned to this job`}
disabled={!canScore}
pending={tailorResume.isPending}
onClick={() => tailorResume.mutate({ id: application.id })}
/>
<ActionRow
icon={<EnvelopeSimpleIcon />}
title={<Trans>Draft a cover letter</Trans>}
description={t`From your resume and the posting`}
pending={draftMessage.isPending && draft?.kind !== "follow-up"}
onClick={() => draftMessage.mutate({ id: application.id, kind: "cover-letter" })}
/>
<ActionRow
icon={<PaperPlaneTiltIcon />}
title={<Trans>Draft a follow-up</Trans>}
description={t`A friendly nudge for the recruiter`}
pending={draftMessage.isPending && draft?.kind === "follow-up"}
onClick={() => draftMessage.mutate({ id: application.id, kind: "follow-up" })}
/>
</div>
{draft && (
<div className="border-primary/10 border-t bg-card/60 p-3">
<div className="mb-1.5 flex items-center justify-between">
<span className="font-medium text-xs">
{draft.kind === "cover-letter" ? <Trans>Cover letter draft</Trans> : <Trans>Follow-up draft</Trans>}
</span>
<div className="flex gap-1">
<button
type="button"
className="inline-flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground"
onClick={() => {
void navigator.clipboard.writeText(draft.text);
toast.success(t`Copied to clipboard.`);
}}
>
<CopyIcon className="size-3.5" /> <Trans>Copy</Trans>
</button>
<button
type="button"
className="text-muted-foreground text-xs hover:text-foreground"
onClick={() => setDraft(null)}
>
<Trans>Dismiss</Trans>
</button>
</div>
</div>
<p className="max-h-40 overflow-y-auto whitespace-pre-wrap text-muted-foreground text-xs leading-relaxed">
{draft.text}
</p>
</div>
)}
</section>
);
}
@@ -0,0 +1,97 @@
import type { Application } from "../types";
import { Trans } from "@lingui/react/macro";
import { FileTextIcon, MapPinIcon } from "@phosphor-icons/react";
import { getInitials } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { tileColor } from "../tile-color";
import { ApplicationActionsMenu } from "./application-actions-menu";
type Props = {
application: Application;
onClick?: () => void;
onEdit?: (application: Application) => void;
className?: string;
dragging?: boolean;
};
export function ApplicationCard({ application, onClick, onEdit, className, dragging }: Props) {
const followUp = application.followUpAt && !application.archived;
return (
// biome-ignore lint/a11y/useSemanticElements: the card nests an actions-menu <button>, so it can't itself be a <button>; it stays keyboard-operable via role + tabIndex + onKeyDown.
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick?.();
}
}}
className={cn(
"group relative w-full cursor-pointer rounded-xl border border-border bg-card p-3 text-left shadow-sm outline-none transition-colors hover:border-foreground/25 focus-visible:ring-2 focus-visible:ring-ring",
dragging && "opacity-60",
className,
)}
>
{onEdit && (
<ApplicationActionsMenu
application={application}
onEdit={onEdit}
showOnHover
className="absolute end-1.5 top-1.5"
/>
)}
<div className="flex items-start gap-2.5">
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg font-bold text-white text-xs",
tileColor(application.company),
)}
>
{getInitials(application.company)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-sm tracking-tight">{application.role}</div>
<div className="truncate text-muted-foreground text-xs">{application.company}</div>
</div>
{followUp && (
<span
title="Needs follow-up"
className={cn("mt-1 size-2 shrink-0 rounded-full bg-amber-500 ring-2 ring-amber-500/25", onEdit && "me-6")}
/>
)}
</div>
{(application.location || application.salary) && (
<div className="mt-2.5 flex items-center gap-2 text-muted-foreground text-xs">
{application.location && (
<span className="flex min-w-0 items-center gap-1">
<MapPinIcon className="size-3 shrink-0" />
<span className="truncate">{application.location}</span>
</span>
)}
{application.location && application.salary && <span className="opacity-40">·</span>}
{application.salary && <span className="font-medium text-foreground">{application.salary}</span>}
</div>
)}
{(application.resumeId || application.source) && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{application.resumeId && (
<span className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/50 px-2 py-0.5 font-medium text-[11px] text-muted-foreground">
<FileTextIcon className="size-3" />
<Trans>Resume linked</Trans>
</span>
)}
{application.source && (
<span className="inline-flex items-center rounded-md border border-border px-2 py-0.5 font-medium text-[11px] text-muted-foreground">
{application.source}
</span>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,438 @@
import type { ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ArrowRightIcon,
ArrowSquareOutIcon,
PencilSimpleIcon,
PlusIcon,
TrashIcon,
XCircleIcon,
XIcon,
} from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@reactive-resume/ui/components/sheet";
import { cn } from "@reactive-resume/utils/style";
import { useConfirm } from "@/hooks/use-confirm";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
import { ApplicationAiCopilot } from "./application-ai-copilot";
import { FileAttachmentField } from "./file-attachment-field";
const stageIndex = (status: ApplicationStatus) => STAGES.findIndex((s) => s.value === status);
type Props = {
application: Application | null;
onOpenChange: (open: boolean) => void;
onEdit: (application: Application) => void;
};
export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Props) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [note, setNote] = useState("");
const id = application?.id;
// Reset the draft note when switching to a different application (React's "adjust state during
// render" pattern) so typed-but-unsent text doesn't leak across applications.
const [noteFor, setNoteFor] = useState(id);
if (id !== noteFor) {
setNoteFor(id);
setNote("");
}
const { data } = useQuery({
...orpc.applications.getById.queryOptions({ input: { id: id ?? "" } }),
enabled: !!id,
...(application ? { initialData: application } : {}),
});
const current = data ?? application;
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
if (id) void queryClient.invalidateQueries({ queryKey: orpc.applications.getById.queryKey({ input: { id } }) });
};
const update = useMutation(
orpc.applications.update.mutationOptions({
onSuccess: invalidate,
onError: () => toast.error(t`Something went wrong. Please try again.`),
}),
);
const addNote = useMutation(
orpc.applications.addNote.mutationOptions({
onSuccess: () => {
setNote("");
invalidate();
},
onError: () => toast.error(t`Couldn't save the note.`),
}),
);
const remove = useMutation(
orpc.applications.delete.mutationOptions({
onSuccess: () => {
invalidate();
toast.success(t`Application deleted.`);
onOpenChange(false);
},
onError: () => toast.error(t`Couldn't delete the application.`),
}),
);
if (!current) return null;
const idx = stageIndex(current.status);
const nextStage = idx >= 0 && idx < STAGES.length - 2 ? STAGES[idx + 1] : null;
return (
<Sheet open={!!application} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full gap-0 data-[side=right]:sm:max-w-lg">
<SheetHeader className="gap-3">
<div className="flex items-start justify-between gap-2 pe-8">
<div className="min-w-0">
<SheetTitle className="truncate">{current.role}</SheetTitle>
<div className="truncate text-muted-foreground text-sm">
{current.company}
{current.location ? ` · ${current.location}` : ""}
</div>
</div>
<Button size="sm" variant="outline" className="shrink-0" onClick={() => onEdit(current)}>
<PencilSimpleIcon />
<Trans>Edit</Trans>
</Button>
</div>
{/* stage stepper */}
<div className="flex gap-1.5">
{STAGES.map((stage, i) => (
<span
key={stage.value}
title={stage.label}
className={cn("h-1.5 flex-1 rounded-full", i <= idx ? "" : "bg-muted")}
style={i <= idx ? { background: stage.color } : undefined}
/>
))}
</div>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{STAGES[idx]?.label ?? current.status}</span>
{nextStage && (
<Button
size="sm"
variant="outline"
disabled={update.isPending}
onClick={() => update.mutate({ id: current.id, status: nextStage.value })}
>
<Trans>Move to</Trans> {nextStage.label}
<ArrowRightIcon />
</Button>
)}
</div>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-4 py-4 [&>*]:shrink-0">
{/* key facts */}
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<Fact label={t`Salary`} value={current.salary} />
<Fact label={t`Source`} value={current.source} />
<Fact label={t`Applied on`} value={new Date(current.appliedAt).toLocaleDateString()} />
</dl>
{current.sourceUrl && (
<a
href={current.sourceUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-primary text-sm hover:underline"
>
<ArrowSquareOutIcon />
<Trans>Job posting</Trans>
</a>
)}
{/* documents: linked resume + cover letter */}
<Section title={t`Documents sent`}>
{current.resumeId ? (
<Link
to="/builder/$resumeId"
params={{ resumeId: current.resumeId }}
className="flex items-center gap-3 rounded-lg border border-border p-2.5 hover:bg-muted/50"
>
<span className="flex size-8 items-center justify-center rounded-md bg-primary/10 font-bold text-[10px] text-primary">
RXR
</span>
<span className="min-w-0 flex-1 truncate text-sm">
<Trans>Linked Reactive Resume</Trans>
</span>
<ArrowSquareOutIcon className="text-muted-foreground" />
</Link>
) : (
<p className="text-muted-foreground text-sm">
<Trans>No resume linked.</Trans>
</p>
)}
<FileAttachmentField
value={
current.resumeFileUrl
? { url: current.resumeFileUrl, name: current.resumeFileName || t`Resume file` }
: null
}
attachLabel={t`Attach a resume file (PDF)`}
disabled={update.isPending}
onChange={(value) =>
update.mutate({
id: current.id,
resumeFileUrl: value?.url ?? null,
resumeFileName: value?.name ?? null,
})
}
/>
<FileAttachmentField
value={
current.coverLetterUrl
? { url: current.coverLetterUrl, name: current.coverLetterName || t`Cover letter` }
: null
}
attachLabel={t`Attach a cover letter (PDF)`}
disabled={update.isPending}
onChange={(value) =>
update.mutate({
id: current.id,
coverLetterUrl: value?.url ?? null,
coverLetterName: value?.name ?? null,
})
}
/>
</Section>
{/* AI copilot — placed high so it's discoverable without scrolling past the timeline */}
<ApplicationAiCopilot application={current} />
{/* contacts */}
<Section title={t`Contacts`}>
<ContactsEditor
key={current.id}
contacts={current.contacts}
pending={update.isPending}
onChange={(contacts) => update.mutate({ id: current.id, contacts })}
/>
</Section>
{/* follow-up */}
{current.followUpAt && (
<Section title={t`Follow-up`}>
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-2.5 text-sm">
<span className="font-medium">{new Date(current.followUpAt).toLocaleDateString()}</span>
{current.followUpNote ? `${current.followUpNote}` : ""}
</div>
</Section>
)}
{/* activity timeline */}
<Section title={t`Timeline & activity`}>
<div className="flex flex-col gap-3">
{[...current.activity]
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
.map((event) => (
<div key={event.id} className="flex gap-2.5 text-sm">
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-muted-foreground/40" />
<div className="min-w-0 flex-1">
<div>{event.text}</div>
<div className="text-muted-foreground text-xs">{new Date(event.at).toLocaleString()}</div>
</div>
</div>
))}
</div>
<div className="mt-3 flex gap-2">
<Input
value={note}
placeholder={t`Add a note or log activity…`}
onChange={(event) => setNote(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && note.trim()) addNote.mutate({ id: current.id, text: note.trim() });
}}
/>
<Button
type="button"
variant="outline"
disabled={!note.trim() || addNote.isPending}
onClick={() => addNote.mutate({ id: current.id, text: note.trim() })}
>
<Trans>Add</Trans>
</Button>
</div>
</Section>
</div>
<div className="flex items-center gap-1 border-border border-t p-4">
{current.status !== "rejected" && (
<Button
size="sm"
variant="ghost"
disabled={update.isPending}
onClick={() => update.mutate({ id: current.id, status: "rejected" })}
>
<XCircleIcon />
<Trans>Mark rejected</Trans>
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => update.mutate({ id: current.id, archived: !current.archived })}
>
{current.archived ? <Trans>Unarchive</Trans> : <Trans>Archive</Trans>}
</Button>
<Button
size="sm"
variant="ghost"
className="ms-auto text-destructive"
disabled={remove.isPending}
onClick={async () => {
const confirmed = await confirm(t`Delete this application?`, {
description: t`"${current.role} · ${current.company}" and its full timeline will be permanently deleted. This can't be undone.`,
confirmText: t`Delete`,
});
if (confirmed) remove.mutate({ id: current.id });
}}
>
<TrashIcon />
<Trans>Delete</Trans>
</Button>
</div>
</SheetContent>
</Sheet>
);
}
function Fact({ label, value }: { label: string; value: string | null | undefined }) {
return (
<div>
<dt className="text-muted-foreground text-xs">{label}</dt>
<dd className="mt-0.5 font-medium">{value || "—"}</dd>
</div>
);
}
type ContactsEditorProps = {
contacts: Contact[];
pending: boolean;
onChange: (contacts: Contact[]) => void;
};
function ContactsEditor({ contacts, pending, onChange }: ContactsEditorProps) {
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState({ name: "", role: "", type: "" });
const reset = () => {
setDraft({ name: "", role: "", type: "" });
setAdding(false);
};
const add = () => {
const name = draft.name.trim();
if (!name) return;
onChange([...contacts, { name, role: draft.role.trim(), type: draft.type.trim() }]);
reset();
};
const removeAt = (index: number) => onChange(contacts.filter((_, i) => i !== index));
return (
<div className="flex flex-col gap-2">
{contacts.map((contact, i) => (
<div key={`${contact.name}-${i}`} className="group flex items-center gap-3 text-sm">
<span className="flex size-8 items-center justify-center rounded-full bg-muted font-medium text-xs">
{contact.name.slice(0, 2).toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{contact.name}</div>
{contact.role && <div className="truncate text-muted-foreground text-xs">{contact.role}</div>}
</div>
{contact.type && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{contact.type}</span>
)}
<button
type="button"
title={t`Remove contact`}
disabled={pending}
className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive disabled:opacity-40 group-hover:opacity-100"
onClick={() => removeAt(i)}
>
<XIcon />
</button>
</div>
))}
{adding ? (
<div className="flex flex-col gap-2 rounded-lg border border-border p-2.5">
<Input
value={draft.name}
placeholder={t`Name`}
autoFocus
onChange={(event) => setDraft((d) => ({ ...d, name: event.target.value }))}
onKeyDown={(event) => {
if (event.key === "Enter") add();
}}
/>
<div className="grid grid-cols-2 gap-2">
<Input
value={draft.role}
placeholder={t`Role (optional)`}
onChange={(event) => setDraft((d) => ({ ...d, role: event.target.value }))}
/>
<Input
value={draft.type}
list="contact-types"
placeholder={t`Label`}
onChange={(event) => setDraft((d) => ({ ...d, type: event.target.value }))}
/>
</div>
<datalist id="contact-types">
<option value="Recruiter" />
<option value="Hiring Manager" />
<option value="Referral" />
<option value="Interviewer" />
</datalist>
<div className="flex justify-end gap-2">
<Button type="button" size="sm" variant="ghost" onClick={reset}>
<Trans>Cancel</Trans>
</Button>
<Button type="button" size="sm" disabled={!draft.name.trim() || pending} onClick={add}>
<Trans>Add</Trans>
</Button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="flex items-center gap-1.5 self-start text-muted-foreground text-xs hover:text-foreground"
>
<PlusIcon /> <Trans>Add contact</Trans>
</button>
)}
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-2">
<h3 className="font-semibold text-muted-foreground text-xs uppercase tracking-wide">{title}</h3>
{children}
</section>
);
}
@@ -0,0 +1,423 @@
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
import type { Application } from "../types";
import type { FileAttachment } from "./file-attachment-field";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { SparkleIcon, XIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@reactive-resume/ui/components/sheet";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { Combobox } from "@/components/ui/combobox";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
import { FileAttachmentField } from "./file-attachment-field";
// Preset source suggestions surfaced via a <datalist>; the field itself stays free-text.
const SOURCE_OPTIONS = ["LinkedIn", "Indeed", "Company Website", "Referral", "Recruiter", "Other"];
const EMPTY = {
company: "",
role: "",
location: "",
salary: "",
source: "",
status: "saved" as ApplicationStatus,
resumeId: "",
tags: [] as string[],
sourceUrl: "",
jobDescription: "",
followUpAt: "",
followUpNote: "",
notes: "",
resumeFile: null as FileAttachment | null,
coverLetter: null as FileAttachment | null,
};
type FormState = typeof EMPTY;
const toAttachment = (url: string | null, name: string | null): FileAttachment | null =>
url ? { url, name: name ?? url } : null;
function toForm(app: Application): FormState {
return {
company: app.company,
role: app.role,
location: app.location ?? "",
salary: app.salary ?? "",
source: app.source ?? "",
status: app.status,
resumeId: app.resumeId ?? "",
tags: app.tags,
sourceUrl: app.sourceUrl ?? "",
jobDescription: app.jobDescription ?? "",
followUpAt: app.followUpAt ? new Date(app.followUpAt).toISOString().slice(0, 10) : "",
followUpNote: app.followUpNote ?? "",
notes: app.notes ?? "",
resumeFile: toAttachment(app.resumeFileUrl, app.resumeFileName),
coverLetter: toAttachment(app.coverLetterUrl, app.coverLetterName),
};
}
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
// When provided, the sheet edits this application instead of creating a new one.
application?: Application | null;
};
export function ApplicationFormSheet({ open, onOpenChange, application }: Props) {
const queryClient = useQueryClient();
const isEditing = !!application;
const [form, setForm] = useState<FormState>(application ? toForm(application) : EMPTY);
// Re-sync the form when the sheet's target changes (a different app, or create ↔ edit).
const [syncedId, setSyncedId] = useState(application?.id ?? null);
if ((application?.id ?? null) !== syncedId) {
setSyncedId(application?.id ?? null);
setForm(application ? toForm(application) : EMPTY);
}
const { data: resumes } = useQuery(orpc.resume.list.queryOptions());
const resumeOptions = (resumes ?? []).map((resume) => ({ value: resume.id, label: resume.name }));
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() });
if (application) {
void queryClient.invalidateQueries({
queryKey: orpc.applications.getById.queryKey({ input: { id: application.id } }),
});
}
};
const create = useMutation(
orpc.applications.create.mutationOptions({
onSuccess: () => {
invalidate();
toast.success(t`Application added to your pipeline.`);
setForm(EMPTY);
onOpenChange(false);
},
onError: () => toast.error(t`Couldn't add the application. Please try again.`),
}),
);
const update = useMutation(
orpc.applications.update.mutationOptions({
onSuccess: () => {
invalidate();
toast.success(t`Application updated.`);
onOpenChange(false);
},
onError: () => toast.error(t`Couldn't save your changes. Please try again.`),
}),
);
const autofill = useMutation(
orpc.applications.ai.autofill.mutationOptions({
onSuccess: (result) => {
setForm((prev) => ({
...prev,
company: result.company || prev.company,
role: result.role || prev.role,
location: result.location || prev.location,
salary: result.salary || prev.salary,
jobDescription: result.jobDescription || prev.jobDescription,
}));
toast.success(t`Filled in what we could from the posting.`);
},
onError: (error) => toast.error(error.message || t`Auto-fill failed. Paste the description instead.`),
}),
);
const pending = create.isPending || update.isPending;
const submit = () => {
if (!form.company.trim() || !form.role.trim()) return;
const payload = {
company: form.company.trim(),
role: form.role.trim(),
status: form.status,
location: form.location.trim() || null,
salary: form.salary.trim() || null,
source: form.source.trim() || null,
resumeId: form.resumeId || null,
tags: form.tags,
sourceUrl: form.sourceUrl.trim() || null,
jobDescription: form.jobDescription.trim() || null,
notes: form.notes.trim() || null,
followUpNote: form.followUpNote.trim() || null,
followUpAt: form.followUpAt ? new Date(form.followUpAt) : null,
resumeFileUrl: form.resumeFile?.url ?? null,
resumeFileName: form.resumeFile?.name ?? null,
coverLetterUrl: form.coverLetter?.url ?? null,
coverLetterName: form.coverLetter?.name ?? null,
};
if (application) update.mutate({ id: application.id, ...payload });
else create.mutate(payload);
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full gap-0 data-[side=right]:sm:max-w-lg">
<SheetHeader>
<SheetTitle>{isEditing ? <Trans>Edit application</Trans> : <Trans>Add application</Trans>}</SheetTitle>
<SheetDescription>
{isEditing ? (
<Trans>Update this application's details.</Trans>
) : (
<Trans>Track a job you're applying to and link the resume you sent.</Trans>
)}
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4 [&>*]:shrink-0">
{/* AI job-posting autofill: extracts the fields below from a posting URL. */}
{!isEditing && (
<div className="rounded-lg border border-border border-dashed p-3">
<Label className="text-muted-foreground text-xs">
<Trans>Paste a job posting URL</Trans>
</Label>
<div className="mt-1.5 flex gap-2">
<Input
value={form.sourceUrl}
placeholder="https://…"
onChange={(event) => set("sourceUrl", event.target.value)}
/>
<Button
type="button"
variant="outline"
disabled={!form.sourceUrl.trim() || autofill.isPending}
onClick={() => autofill.mutate({ sourceUrl: form.sourceUrl.trim() })}
>
<SparkleIcon />
{autofill.isPending ? <Trans>Reading</Trans> : <Trans>Auto-fill</Trans>}
</Button>
</div>
<p className="mt-1.5 text-[11px] text-muted-foreground">
<Trans>Let AI read the posting and fill the fields below.</Trans>
</p>
</div>
)}
<Field label={t`Company`} required>
<Input value={form.company} onChange={(event) => set("company", event.target.value)} />
</Field>
<Field label={t`Role / title`} required>
<Input value={form.role} onChange={(event) => set("role", event.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={t`Location`}>
<Input
value={form.location}
list="application-locations"
placeholder={t`Remote, Hybrid, a city…`}
onChange={(event) => set("location", event.target.value)}
/>
<datalist id="application-locations">
<option value="Remote" />
<option value="Hybrid" />
<option value="In-office" />
</datalist>
</Field>
<Field label={t`Salary range`}>
<Input value={form.salary} onChange={(event) => set("salary", event.target.value)} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label={t`Source`}>
<Input
value={form.source}
list="application-sources"
placeholder={t`LinkedIn, Referral…`}
onChange={(event) => set("source", event.target.value)}
/>
<datalist id="application-sources">
{SOURCE_OPTIONS.map((option) => (
<option key={option} value={option} />
))}
</datalist>
</Field>
<Field label={t`Stage`}>
<Combobox
className="w-full"
value={form.status}
options={STAGES.map((s) => ({ value: s.value, label: s.label }))}
onValueChange={(value) => value && set("status", value)}
/>
</Field>
</div>
{/* Resume: link a live Reactive Resume (unlocks AI) or upload the exact PDF you sent. */}
<Field label={t`Resume`}>
<div className="flex flex-col gap-2">
<Combobox
className="w-full"
value={form.resumeId || null}
options={resumeOptions}
placeholder={t`Link a Reactive Resume (recommended)`}
showClear
emptyMessage={t`No resumes yet.`}
onValueChange={(value) => set("resumeId", value ?? "")}
/>
<FileAttachmentField
value={form.resumeFile}
attachLabel={t`Or upload a resume PDF`}
onChange={(value) => set("resumeFile", value)}
/>
<p className="text-[11px] text-muted-foreground">
<Trans>Linking a Reactive Resume enables AI match scoring and tailoring.</Trans>
</p>
</div>
</Field>
<Field label={t`Cover letter`}>
<FileAttachmentField
value={form.coverLetter}
attachLabel={t`Attach a cover letter (PDF)`}
onChange={(value) => set("coverLetter", value)}
/>
</Field>
<Field label={t`Tags`}>
<TagsField value={form.tags} suggestions={allTags ?? []} onChange={(tags) => set("tags", tags)} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={t`Follow-up date`}>
<Input type="date" value={form.followUpAt} onChange={(event) => set("followUpAt", event.target.value)} />
</Field>
<Field label={t`Follow-up note`}>
<Input value={form.followUpNote} onChange={(event) => set("followUpNote", event.target.value)} />
</Field>
</div>
<Field label={t`Job description`}>
<Textarea
value={form.jobDescription}
rows={3}
placeholder={t`Paste the posting — powers AI match scoring and tailoring.`}
onChange={(event) => set("jobDescription", event.target.value)}
/>
</Field>
<Field label={t`Notes`}>
<Textarea
value={form.notes}
rows={3}
placeholder={t`Referred by…, things to emphasize, etc.`}
onChange={(event) => set("notes", event.target.value)}
/>
</Field>
</div>
<SheetFooter className="flex-row justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="button" disabled={!form.company.trim() || !form.role.trim() || pending} onClick={submit}>
{isEditing ? <Trans>Save changes</Trans> : <Trans>Add to pipeline</Trans>}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
return (
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">
{label}
{required && <span className="text-destructive"> *</span>}
</Label>
{children}
</div>
);
}
type TagsFieldProps = {
value: string[];
suggestions: string[];
onChange: (tags: string[]) => void;
};
// Type-and-Enter tag input with chips + an autocomplete datalist of the user's existing tags.
function TagsField({ value, suggestions, onChange }: TagsFieldProps) {
const [draft, setDraft] = useState("");
const add = () => {
const tag = draft.trim();
if (!tag || value.includes(tag)) {
setDraft("");
return;
}
onChange([...value, tag]);
setDraft("");
};
return (
<div className="flex flex-col gap-2">
<Input
value={draft}
list="application-tags"
placeholder={t`Add a tag and press Enter…`}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
add();
}
}}
onBlur={add}
/>
<datalist id="application-tags">
{suggestions.map((tag) => (
<option key={tag} value={tag} />
))}
</datalist>
{value.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{value.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-muted-foreground text-xs"
>
{tag}
<button
type="button"
title={t`Remove tag`}
className="hover:text-destructive"
onClick={() => onChange(value.filter((t) => t !== tag))}
>
<XIcon className="size-3" />
</button>
</span>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,164 @@
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>
);
}
@@ -0,0 +1,93 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { FilePdfIcon, UploadSimpleIcon, XIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useRef } from "react";
import { toast } from "sonner";
import { orpc } from "@/libs/orpc/client";
export type FileAttachment = { url: string; name: string };
type Props = {
// The uploaded file, or null when nothing is attached yet.
value: FileAttachment | null;
onChange: (value: FileAttachment | null) => void;
// Copy for the empty-state button, e.g. "Attach a cover letter (PDF)".
attachLabel: string;
disabled?: boolean;
};
// PDF-only upload to the shared storage route, used for both the resume file and cover letter.
// Handles upload + best-effort delete; persistence of the returned URL is the parent's job.
export function FileAttachmentField({ value, onChange, attachLabel, disabled }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const upload = useMutation(orpc.storage.uploadFile.mutationOptions({ meta: { noInvalidate: true } }));
const remove = useMutation(orpc.storage.deleteFile.mutationOptions({ meta: { noInvalidate: true } }));
const onSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.type !== "application/pdf") {
toast.error(t`Please upload a PDF file.`);
return;
}
const toastId = toast.loading(t`Uploading…`);
upload.mutate(file, {
onSuccess: ({ url }) => {
toast.dismiss(toastId);
onChange({ url, name: file.name });
if (inputRef.current) inputRef.current.value = "";
},
onError: () => toast.error(t`Couldn't upload the file. Please try again.`, { id: toastId }),
});
};
const clear = () => {
if (!value) return;
// Best-effort delete of the stored file; the storage route defaults a bare filename to the
// user's upload dir. Clear regardless so the UI reflects the removal.
const filename = new URL(value.url, window.location.origin).pathname.split("/").pop();
if (filename) remove.mutate({ filename });
onChange(null);
};
return (
<>
{value ? (
<div className="flex items-center gap-3 rounded-lg border border-border p-2.5">
<span className="flex size-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<FilePdfIcon />
</span>
<a
href={value.url}
target="_blank"
rel="noreferrer"
className="min-w-0 flex-1 truncate text-sm hover:underline"
>
{value.name}
</a>
<button
type="button"
title={t`Remove file`}
disabled={disabled}
className="text-muted-foreground hover:text-destructive disabled:opacity-40"
onClick={clear}
>
<XIcon />
</button>
</div>
) : (
<button
type="button"
disabled={disabled || upload.isPending}
onClick={() => inputRef.current?.click()}
className="flex w-full items-center gap-2 rounded-lg border border-border border-dashed p-2.5 text-muted-foreground text-sm hover:bg-muted/50 disabled:opacity-60"
>
<UploadSimpleIcon />
{upload.isPending ? <Trans>Uploading</Trans> : attachLabel}
</button>
)}
<input ref={inputRef} type="file" accept="application/pdf" className="hidden" onChange={onSelect} />
</>
);
}
@@ -0,0 +1,169 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CheckCircleIcon, UploadSimpleIcon } from "@phosphor-icons/react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@reactive-resume/ui/components/sheet";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { orpc } from "@/libs/orpc/client";
import { mapCsvToApplications, parseCsv } from "../csv";
import { applicationsListQueryKey } from "../queries";
const MAX_IMPORT = 500;
const SAMPLE =
"Company,Role,Stage,Location,Salary,Source,Tags\nStripe,Frontend Engineer,applied,Remote,$180k,LinkedIn,remote;react";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function ImportApplicationsSheet({ open, onOpenChange }: Props) {
const queryClient = useQueryClient();
const [text, setText] = useState("");
const fileRef = useRef<HTMLInputElement>(null);
const parsed = useMemo(() => (text.trim() ? mapCsvToApplications(parseCsv(text)) : null), [text]);
// The import endpoint caps a batch at 500; slice client-side and surface the overflow so the
// user knows to split rather than getting a generic server rejection.
const importable = parsed ? parsed.rows.slice(0, MAX_IMPORT) : [];
const overflow = (parsed?.rows.length ?? 0) - importable.length;
const resetFile = () => {
if (fileRef.current) fileRef.current.value = "";
};
const importMutation = useMutation(
orpc.applications.import.mutationOptions({
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() });
toast.success(t`Imported ${result.imported} application(s).`);
setText("");
resetFile();
onOpenChange(false);
},
onError: () => toast.error(t`Import failed. Check the CSV and try again.`),
}),
);
const onFile = (file: File | undefined) => {
if (!file) return;
file.text().then(setText);
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full gap-0 data-[side=right]:sm:max-w-lg">
<SheetHeader>
<SheetTitle>
<Trans>Import from CSV</Trans>
</SheetTitle>
<SheetDescription>
<Trans>
Paste rows or upload a .csv. We map columns like Company, Role, Stage, Salary, Source and Tags.
</Trans>
</SheetDescription>
</SheetHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 pb-4">
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => fileRef.current?.click()}>
<UploadSimpleIcon />
<Trans>Upload .csv</Trans>
</Button>
<Button size="sm" variant="ghost" onClick={() => setText(SAMPLE)}>
<Trans>Use sample</Trans>
</Button>
<input
ref={fileRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(event) => onFile(event.target.files?.[0])}
/>
</div>
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">
<Trans>CSV data</Trans>
</Label>
<Textarea
value={text}
rows={8}
placeholder="Company,Role,Stage,…"
className="font-mono text-xs"
onChange={(event) => setText(event.target.value)}
/>
</div>
{parsed && (
<div className="rounded-lg border border-border p-3 text-sm">
<div className="flex items-center gap-2 font-medium">
<CheckCircleIcon className="text-emerald-500" />
<Trans>{importable.length} ready to import</Trans>
{parsed.skipped > 0 && (
<span className="text-muted-foreground text-xs">
· <Trans>{parsed.skipped} skipped (missing company/role)</Trans>
</span>
)}
</div>
{overflow > 0 && (
<p className="mt-1.5 text-amber-600 text-xs dark:text-amber-500">
<Trans>
Only the first {MAX_IMPORT} rows import at once {overflow} left out. Split the file to import the
rest.
</Trans>
</p>
)}
{parsed.recognized.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{parsed.recognized.map((field) => (
<Badge key={field} variant="secondary" className="text-[10px]">
{field}
</Badge>
))}
</div>
)}
{parsed.rows.length > 0 && (
<ul className="mt-2 space-y-0.5 text-muted-foreground text-xs">
{parsed.rows.slice(0, 4).map((row, i) => (
<li key={`${row.company}-${i}`} className="truncate">
{row.role} · {row.company}
</li>
))}
{parsed.rows.length > 4 && <li>+{parsed.rows.length - 4} more</li>}
</ul>
)}
</div>
)}
</div>
<SheetFooter className="flex-row justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
disabled={importable.length === 0 || importMutation.isPending}
onClick={() => importable.length > 0 && importMutation.mutate({ items: importable })}
>
<Trans>Import {importable.length}</Trans>
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,325 @@
import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useRef } from "react";
import { toast } from "sonner";
import { Button } from "@reactive-resume/ui/components/button";
import { orpc } from "@/libs/orpc/client";
import { computeInsights, computeTimeline } from "../insights";
export function ApplicationInsights({ applications }: { applications: Application[] }) {
const { data } = useQuery(orpc.applications.stats.queryOptions({}));
// Weekly application velocity — derived from the already-loaded list, matching the stats
// population (archived excluded), so no extra endpoint is needed.
const timeline = useMemo(
() => computeTimeline(applications.filter((app) => !app.archived).map((app) => new Date(app.appliedAt))),
[applications],
);
const maxWeek = Math.max(1, ...timeline.map((bucket) => bucket.count));
if (!data) return <div className="h-40 animate-pulse rounded-xl bg-muted/40" />;
const insights = computeInsights(data.byStage);
const maxSource = Math.max(1, ...data.bySource.map((s) => s.count));
if (insights.total === 0) {
return (
<p className="py-16 text-center text-muted-foreground text-sm">
<Trans>No applications yet add a few to see your funnel and reply rates.</Trans>
</p>
);
}
return (
<div className="flex max-w-4xl flex-col gap-4 overflow-y-auto pb-6">
<p className="text-muted-foreground text-xs">
<Trans>Pipeline health across all applications</Trans>
</p>
{/* stat tiles */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{insights.tiles.map((tile) => (
<div key={tile.label} className="rounded-xl border border-border p-4">
<div className="text-muted-foreground text-xs">{tile.label}</div>
<div className="mt-2 font-bold text-2xl tracking-tight">{tile.value}</div>
<div className="mt-1 text-muted-foreground text-xs">{tile.sub}</div>
</div>
))}
</div>
<PipelineFlow insights={insights} />
<div className="grid gap-4 lg:grid-cols-2">
{/* application velocity over time */}
<div className="rounded-xl border border-border p-5">
<h3 className="font-semibold text-sm">
<Trans>Applications over time</Trans>
</h3>
<p className="mt-0.5 text-muted-foreground text-xs">
<Trans>Applications sent per week (last 8 weeks)</Trans>
</p>
<div className="mt-4 flex items-end gap-2">
{timeline.map((bucket) => (
<div key={bucket.label} className="flex flex-1 flex-col items-center gap-1">
<div className="flex h-28 w-full flex-col justify-end">
<span className="mb-1 text-center text-[10px] text-muted-foreground tabular-nums">
{bucket.count || ""}
</span>
<div
className="w-full rounded-t bg-primary/60"
style={{ height: `${bucket.count ? Math.max((bucket.count / maxWeek) * 100, 8) : 0}%` }}
/>
</div>
<span className="text-[10px] text-muted-foreground tabular-nums">{bucket.label}</span>
</div>
))}
</div>
</div>
{/* sources */}
<div className="rounded-xl border border-border p-5">
<h3 className="font-semibold text-sm">
<Trans>Where applications come from</Trans>
</h3>
<p className="mt-0.5 text-muted-foreground text-xs">
<Trans>Count by source</Trans>
</p>
<div className="mt-4 flex flex-col gap-3">
{data.bySource.length === 0 ? (
<p className="text-muted-foreground text-sm">
<Trans>No source data yet.</Trans>
</p>
) : (
data.bySource.map((row) => (
<div key={row.source} className="flex items-center gap-3 text-xs">
<span className="w-28 shrink-0 truncate font-medium">{row.source}</span>
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-foreground/70"
style={{ width: `${Math.max((row.count / maxSource) * 100, 3)}%` }}
/>
</div>
<span className="w-6 text-right text-muted-foreground">{row.count}</span>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}
// Vibrant, dark-mode palette for the pipeline snapshot — brighter than the muted board stage
// colors so the chart pops both on screen (preview) and in the exported PNG.
const FLOW_COLORS = ["#a5b4fc", "#818cf8", "#22d3ee", "#fbbf24", "#34d399"];
const FLOW_BG = "#0a0a0f";
const FLOW_REJECTED = "#fb7185";
const FLOW_FONT = '"IBM Plex Sans Variable", "IBM Plex Sans", ui-sans-serif, sans-serif';
// RxR mark ~18px wide in the 256-unit icon viewBox (the mark's glyphs span y ≈ 36220).
const ICON_SCALE = 18 / 256;
function toBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return btoa(binary);
}
// A rasterized SVG (loaded as an <img>) can't reach the page's webfonts, so the exported PNG falls
// back to a system font unless the font is inlined. Find the IBM Plex Sans woff2 the app already
// loaded (basic-latin subset covers the chart's English labels), base64 it, and return an
// @font-face the export SVG can embed. Returns null on any failure so export still proceeds.
async function ibmPlexFontFace(): Promise<string | null> {
for (const sheet of Array.from(document.styleSheets)) {
let rules: CSSRuleList | undefined;
try {
rules = sheet.cssRules;
} catch {
continue; // cross-origin stylesheet — not readable
}
for (const rule of Array.from(rules ?? [])) {
if (!(rule instanceof CSSFontFaceRule)) continue;
const family = rule.style.getPropertyValue("font-family").replace(/["']/g, "");
if (!family.includes("IBM Plex Sans")) continue;
if ((rule.style.getPropertyValue("font-style") || "normal") !== "normal") continue;
// Keep only the basic-latin subset (covers the chart's English labels). CSSOM normalizes
// its range to "U+0-FF" — i.e. "U+" then all-zero start — so match that, not "U+0000".
const range = rule.style.getPropertyValue("unicode-range");
if (range && !/U\+0{1,4}-/i.test(range)) continue;
const url = rule.style.getPropertyValue("src").match(/url\(["']?([^"')]+\.woff2)["']?\)/)?.[1];
if (!url) continue;
try {
const buffer = await (await fetch(url)).arrayBuffer();
return `@font-face{font-family:"IBM Plex Sans Variable";font-style:normal;font-weight:100 700;src:url(data:font/woff2;base64,${toBase64(buffer)}) format("woff2");}`;
} catch {
return null;
}
}
}
return null;
}
// A funnel-flow snapshot (the shareable picture). Hand-drawn SVG with inline fills so it exports to
// PNG without any library. Rendered dark + vibrant so the on-screen preview matches the export;
// the "Tracked using Reactive Resume" mark is injected only when exporting.
function PipelineFlow({ insights }: { insights: ReturnType<typeof computeInsights> }) {
const svgRef = useRef<SVGSVGElement>(null);
const W = 800;
const H = 340;
const padX = 40;
const midY = 190;
const maxBarH = 150;
const barW = 34;
const n = insights.funnel.length;
const slotW = (W - padX * 2) / n;
const maxReached = Math.max(1, ...insights.funnel.map((f) => f.reached));
const bars = insights.funnel.map((f, i) => {
const h = Math.max((f.reached / maxReached) * maxBarH, 4);
const x = padX + slotW * i + slotW / 2 - barW / 2;
const yTop = midY - h / 2;
return { ...f, color: FLOW_COLORS[i] ?? f.color, x, yTop, h, cx: x + barW / 2 };
});
const exportPng = async () => {
const svg = svgRef.current;
if (!svg) return;
// Reveal the export-only watermark on a clone so the on-screen chart stays clean.
const clone = svg.cloneNode(true) as SVGSVGElement;
for (const el of clone.querySelectorAll<SVGElement>("[data-export-only]")) el.style.display = "";
// Inline the brand font so the rasterized PNG renders in IBM Plex Sans, not a system fallback.
const fontFace = await ibmPlexFontFace();
if (fontFace) {
const styleEl = document.createElementNS("http://www.w3.org/2000/svg", "style");
styleEl.textContent = fontFace;
clone.querySelector("defs")?.prepend(styleEl);
}
const xml = new XMLSerializer().serializeToString(clone);
const svg64 = `data:image/svg+xml;base64,${btoa(String.fromCharCode(...new TextEncoder().encode(xml)))}`;
const image = new Image();
image.onload = () => {
const scale = 3; // high-resolution output
const canvas = document.createElement("canvas");
canvas.width = W * scale;
canvas.height = H * scale;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = FLOW_BG;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
const link = document.createElement("a");
link.download = "pipeline-flow.png";
link.href = canvas.toDataURL("image/png");
link.click();
toast.success(t`Exported pipeline-flow.png`);
};
image.src = svg64;
};
return (
<div className="rounded-xl border border-border p-5">
<div className="flex items-start justify-between gap-4">
<h3 className="font-semibold text-sm">
<Trans>Where your applications went</Trans>
</h3>
<Button size="sm" variant="outline" onClick={() => void exportPng()}>
<DownloadSimpleIcon />
<Trans>Export PNG</Trans>
</Button>
</div>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
className="mt-3 w-full rounded-xl"
style={{ fontFamily: FLOW_FONT }}
role="img"
aria-label={t`Pipeline flow`}
>
<defs>
{bars.slice(0, -1).map((bar, i) => {
const next = bars[i + 1];
if (!next) return null;
return (
<linearGradient key={`grad-${bar.label}`} id={`flow-grad-${i}`} x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={bar.color} />
<stop offset="100%" stopColor={next.color} />
</linearGradient>
);
})}
</defs>
<rect x={0} y={0} width={W} height={H} rx={16} fill={FLOW_BG} />
<text x={padX} y={40} fontSize={17} fontWeight={700} fill="#fafafa">
{t`Job search pipeline`}
</text>
<text x={padX} y={60} fontSize={12} fill="#71717a">
{t`${insights.total} applications tracked`}
</text>
{insights.rejected > 0 && (
<g>
<circle cx={W - padX - 96} cy={54} r={4} fill={FLOW_REJECTED} />
<text x={W - padX - 86} y={58} fontSize={12} fill="#a1a1aa">
{t`${insights.rejected} rejected`}
</text>
</g>
)}
{/* connecting bands drawn center-to-center so they pass *behind* the bars (rendered
next, on top), leaving a single continuous flow with no seam at each stage. */}
{bars.slice(0, -1).map((bar, i) => {
const next = bars[i + 1];
if (!next) return null;
const x1 = bar.cx;
const x2 = next.cx;
const cxa = (x1 + x2) / 2;
return (
<path
key={`band-${bar.label}`}
d={`M${x1},${bar.yTop} C${cxa},${bar.yTop} ${cxa},${next.yTop} ${x2},${next.yTop} L${x2},${next.yTop + next.h} C${cxa},${next.yTop + next.h} ${cxa},${bar.yTop + bar.h} ${x1},${bar.yTop + bar.h} Z`}
fill={`url(#flow-grad-${i})`}
opacity={0.32}
/>
);
})}
{/* bars + labels */}
{bars.map((bar, i) => (
<g key={`bar-${bar.label}`}>
<rect x={bar.x} y={bar.yTop} width={barW} height={bar.h} rx={6} fill={bar.color} />
<text x={bar.cx} y={bar.yTop - 10} textAnchor="middle" fontSize={15} fontWeight={700} fill="#fafafa">
{bar.reached}
</text>
<text x={bar.cx} y={H - 42} textAnchor="middle" fontSize={12} fontWeight={500} fill="#e4e4e7">
{bar.label}
</text>
{i > 0 && (
<text x={bar.cx} y={H - 26} textAnchor="middle" fontSize={11} fill={bar.color}>
{bar.conv}
</text>
)}
</g>
))}
{/* export-only watermark: the Reactive Resume mark, bottom-right, 8px from each edge.
Paths are apps/web/public/icon/dark.svg verbatim (fill #FAFAFA). */}
<g
data-export-only
style={{ display: "none" }}
transform={`translate(${W - 8 - 256 * ICON_SCALE}, ${H - 8 - 220 * ICON_SCALE}) scale(${ICON_SCALE})`}
fill="#FAFAFA"
fillRule="evenodd"
clipRule="evenodd"
>
<path d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z" />
<path d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2517 137.634 61.6567 137.614C75.0665 136.968 85.1471 135.549 96.385 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7679 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z" />
</g>
</svg>
</div>
);
}
@@ -0,0 +1,393 @@
import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArchiveIcon, ArrowRightIcon, TagIcon, TrashIcon } from "@phosphor-icons/react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { Checkbox } from "@reactive-resume/ui/components/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Input } from "@reactive-resume/ui/components/input";
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { getInitials } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
import { tileColor } from "../tile-color";
import { ApplicationActionsMenu } from "./application-actions-menu";
const PAGE_SIZE = 25;
const stageOf = (status: string) => STAGES.find((s) => s.value === status);
type Props = {
applications: Application[];
onOpen: (application: Application) => void;
onEdit: (application: Application) => void;
};
export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
const queryClient = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [page, setPage] = useState(0);
// Drop selected rows that are no longer in the current (filtered) set, so the bulk-action bar
// never reports a count for rows the user can't see.
useEffect(() => {
setSelected((prev) => {
if (prev.size === 0) return prev;
const visible = new Set(applications.map((app) => app.id));
const next = new Set([...prev].filter((id) => visible.has(id)));
return next.size === prev.size ? prev : next;
});
}, [applications]);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
};
const clearSelection = () => setSelected(new Set());
const bulkUpdate = useMutation(
orpc.applications.bulkUpdate.mutationOptions({
onSuccess: () => {
invalidate();
clearSelection();
},
onError: () => toast.error(t`Bulk update failed. Please try again.`),
}),
);
const bulkDelete = useMutation(
orpc.applications.bulkDelete.mutationOptions({
onSuccess: (result) => {
invalidate();
clearSelection();
toast.success(t`Deleted ${result.deleted} application(s).`);
},
onError: () => toast.error(t`Bulk delete failed. Please try again.`),
}),
);
const pageCount = Math.max(1, Math.ceil(applications.length / PAGE_SIZE));
const safePage = Math.min(page, pageCount - 1);
const rows = useMemo(
() => applications.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE),
[applications, safePage],
);
const pageIds = rows.map((row) => row.id);
const allChecked = pageIds.length > 0 && pageIds.every((id) => selected.has(id));
const someChecked = pageIds.some((id) => selected.has(id));
const toggleAll = () => {
setSelected((prev) => {
const next = new Set(prev);
if (allChecked) for (const id of pageIds) next.delete(id);
else for (const id of pageIds) next.add(id);
return next;
});
};
const toggleOne = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const ids = [...selected];
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{selected.size > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-xl bg-foreground px-3 py-2 text-background">
<span className="font-semibold text-sm">
{selected.size} <Trans>selected</Trans>
</span>
<span className="mx-1 h-4 w-px bg-background/25" />
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="sm" variant="secondary" className="h-7">
<ArrowRightIcon />
<Trans>Move stage</Trans>
</Button>
}
/>
<DropdownMenuContent align="start">
{STAGES.map((stage) => (
<DropdownMenuItem key={stage.value} onClick={() => bulkUpdate.mutate({ ids, status: stage.value })}>
<span className="size-2 rounded-sm" style={{ background: stage.color }} />
{stage.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<AddTagPopover onAdd={(tag) => bulkUpdate.mutate({ ids, addTags: [tag] })} />
<Button
size="sm"
variant="secondary"
className="h-7"
onClick={() => bulkUpdate.mutate({ ids, archived: true })}
>
<ArchiveIcon />
<Trans>Archive</Trans>
</Button>
<Button
size="sm"
variant="secondary"
className="h-7 text-destructive"
onClick={() => bulkDelete.mutate({ ids })}
>
<TrashIcon />
<Trans>Delete</Trans>
</Button>
<Button
size="sm"
variant="ghost"
className="ms-auto h-7 text-background hover:bg-background/15"
onClick={clearSelection}
>
<Trans>Clear</Trans>
</Button>
</div>
)}
{/* Desktop: full table. Mobile: a stacked card list (below) instead of a 900px h-scroll. */}
<div className="min-h-0 flex-1 overflow-auto rounded-xl border border-border max-sm:hidden">
<table className="w-full min-w-[900px] border-collapse text-sm">
<thead className="sticky top-0 z-10 bg-muted/50 backdrop-blur">
<tr className="[&>th]:whitespace-nowrap [&>th]:px-3 [&>th]:py-2.5 [&>th]:text-left [&>th]:font-medium [&>th]:text-muted-foreground [&>th]:text-xs [&>th]:uppercase [&>th]:tracking-wide">
<th className="w-10">
<Checkbox
checked={allChecked}
indeterminate={someChecked && !allChecked}
onCheckedChange={toggleAll}
aria-label={t`Select all`}
/>
</th>
<th>
<Trans>Company / Role</Trans>
</th>
<th>
<Trans>Stage</Trans>
</th>
<th>
<Trans>Location</Trans>
</th>
<th>
<Trans>Salary</Trans>
</th>
<th>
<Trans>Tags</Trans>
</th>
<th>
<Trans>Source</Trans>
</th>
<th>
<Trans>Applied</Trans>
</th>
<th className="w-10">
<span className="sr-only">
<Trans>Actions</Trans>
</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((app) => {
const stage = stageOf(app.status);
return (
<tr
key={app.id}
className={cn(
"border-border border-t transition-colors hover:bg-muted/40",
selected.has(app.id) && "bg-primary/5",
)}
>
<td className="px-3 py-2">
<Checkbox
checked={selected.has(app.id)}
onCheckedChange={() => toggleOne(app.id)}
aria-label={t`Select ${app.company}`}
/>
</td>
<td className="px-3 py-2">
<button type="button" className="flex items-center gap-2.5 text-left" onClick={() => onOpen(app)}>
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md font-bold text-[10px] text-white",
tileColor(app.company),
)}
>
{getInitials(app.company)}
</span>
<div className="min-w-0">
<div className="truncate font-medium hover:underline">{app.role}</div>
<div className="truncate text-muted-foreground text-xs">{app.company}</div>
</div>
</button>
</td>
<td className="whitespace-nowrap px-3 py-2">
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm" style={{ background: stage?.color }} />
{stage?.label ?? app.status}
</span>
</td>
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">{app.location || "—"}</td>
<td className="whitespace-nowrap px-3 py-2 font-medium">{app.salary || "—"}</td>
<td className="px-3 py-2">
<div className="flex max-w-40 flex-wrap gap-1">
{app.tags.slice(0, 2).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px]">
{tag}
</Badge>
))}
{app.tags.length > 2 && (
<span className="text-muted-foreground text-xs">+{app.tags.length - 2}</span>
)}
</div>
</td>
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">{app.source || "—"}</td>
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">
{new Date(app.appliedAt).toLocaleDateString()}
</td>
<td className="px-1 py-2">
<ApplicationActionsMenu application={app} onEdit={onEdit} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile: stacked cards (same paginated rows), tap to open. */}
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-auto sm:hidden">
{rows.map((app) => {
const stage = stageOf(app.status);
return (
<div
key={app.id}
className={cn(
"flex items-center gap-3 rounded-xl border border-border p-3",
selected.has(app.id) && "bg-primary/5",
)}
>
<Checkbox
checked={selected.has(app.id)}
onCheckedChange={() => toggleOne(app.id)}
aria-label={t`Select ${app.company}`}
/>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
onClick={() => onOpen(app)}
>
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-md font-bold text-[10px] text-white",
tileColor(app.company),
)}
>
{getInitials(app.company)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm">{app.role}</div>
<div className="truncate text-muted-foreground text-xs">{app.company}</div>
<div className="mt-1 flex items-center gap-1.5 text-xs">
<span className="size-2 shrink-0 rounded-sm" style={{ background: stage?.color }} />
<span className="text-muted-foreground">{stage?.label ?? app.status}</span>
{app.salary && <span className="truncate font-medium">· {app.salary}</span>}
</div>
</div>
</button>
<ApplicationActionsMenu application={app} onEdit={onEdit} />
</div>
);
})}
</div>
<div className="flex items-center gap-3 text-muted-foreground text-sm">
<span>
<Trans>
Showing {rows.length} of {applications.length}
</Trans>
</span>
{pageCount > 1 && (
<div className="ms-auto flex items-center gap-1.5">
<Button size="sm" variant="outline" disabled={safePage === 0} onClick={() => setPage(safePage - 1)}>
</Button>
<span className="text-xs">
{safePage + 1} / {pageCount}
</span>
<Button
size="sm"
variant="outline"
disabled={safePage >= pageCount - 1}
onClick={() => setPage(safePage + 1)}
>
</Button>
</div>
)}
</div>
</div>
);
}
function AddTagPopover({ onAdd }: { onAdd: (tag: string) => void }) {
const [value, setValue] = useState("");
const [open, setOpen] = useState(false);
const submit = () => {
const tag = value.trim();
if (!tag) return;
onAdd(tag);
setValue("");
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button size="sm" variant="secondary" className="h-7">
<TagIcon />
<Trans>Add tag</Trans>
</Button>
}
/>
<PopoverContent align="start" className="w-56 p-2">
<div className="flex gap-2">
<Input
autoFocus
value={value}
placeholder={t`New tag…`}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && submit()}
/>
<Button size="sm" onClick={submit}>
<Trans>Add</Trans>
</Button>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { mapCsvToApplications, parseCsv } from "./csv";
describe("parseCsv", () => {
it("parses quoted fields with commas and newlines", () => {
const table = parseCsv('Company,Role\n"Acme, Inc.","Eng, Sr"\nBeta,"Line1\nLine2"');
expect(table[1]).toEqual(["Acme, Inc.", "Eng, Sr"]);
expect(table[2]).toEqual(["Beta", "Line1\nLine2"]);
});
it("handles escaped quotes and CRLF", () => {
const table = parseCsv('A,B\r\n"say ""hi""",x\r\n');
expect(table[1]).toEqual(['say "hi"', "x"]);
});
it("drops fully blank rows", () => {
expect(parseCsv("A,B\n\n1,2\n").length).toBe(2);
});
it("strips a UTF-8 BOM so the first header still maps", () => {
const { rows } = mapCsvToApplications(parseCsv("Company,Role\nStripe,Eng"));
expect(rows).toHaveLength(1);
expect(rows[0]?.company).toBe("Stripe");
});
});
describe("mapCsvToApplications", () => {
it("maps aliased headers and coerces status/tags", () => {
const csv = 'Company,Job Title,Stage,Salary,Tags\nStripe,Frontend,Interview,$180k,"remote;react"';
const { rows, recognized } = mapCsvToApplications(parseCsv(csv));
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
company: "Stripe",
role: "Frontend",
status: "interview",
salary: "$180k",
tags: ["remote", "react"],
});
expect(recognized).toEqual(expect.arrayContaining(["company", "role", "status", "salary", "tags"]));
});
it("skips rows missing company or role and drops invalid status", () => {
const csv = "company,role,status\nStripe,Eng,bogus\n,NoCompany,applied\nAcme,,saved";
const { rows, skipped } = mapCsvToApplications(parseCsv(csv));
expect(rows).toHaveLength(1);
expect(rows[0]?.status).toBeUndefined(); // "bogus" dropped
expect(skipped).toBe(2);
});
});
+137
View File
@@ -0,0 +1,137 @@
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
import { applicationStatusSchema } from "@reactive-resume/schema/applications/data";
// Minimal RFC-4180-ish CSV parser: handles quoted fields, escaped quotes (""), commas and
// newlines inside quotes, and \r\n. Enough for spreadsheet exports; not a full streaming parser.
export function parseCsv(input: string): string[][] {
// Strip a UTF-8 BOM (Excel prepends one) — trim() doesn't remove , so the first header
// would otherwise become "company" and never match an alias.
const text = input.replace(/^/, "");
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (inQuotes) {
if (char === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else {
inQuotes = false;
}
} else {
field += char;
}
continue;
}
if (char === '"') inQuotes = true;
else if (char === ",") {
row.push(field);
field = "";
} else if (char === "\n" || char === "\r") {
if (char === "\r" && text[i + 1] === "\n") i++;
row.push(field);
rows.push(row);
row = [];
field = "";
} else {
field += char;
}
}
// Flush the trailing field/row if the file didn't end in a newline.
if (field !== "" || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows.filter((r) => r.some((cell) => cell.trim() !== ""));
}
type ParsedApplication = {
company: string;
role: string;
status?: ApplicationStatus;
location?: string;
salary?: string;
source?: string;
notes?: string;
sourceUrl?: string;
tags?: string[];
};
// Header aliases → canonical field. Matched case-insensitively after trimming.
const HEADER_ALIASES: Record<string, keyof ParsedApplication> = {
company: "company",
employer: "company",
organization: "company",
role: "role",
title: "role",
position: "role",
"job title": "role",
status: "status",
stage: "status",
location: "location",
salary: "salary",
"salary range": "salary",
compensation: "salary",
source: "source",
notes: "notes",
note: "notes",
url: "sourceUrl",
link: "sourceUrl",
"job url": "sourceUrl",
"job posting": "sourceUrl",
tags: "tags",
};
export type CsvMapResult = {
rows: ParsedApplication[];
skipped: number;
headers: string[];
recognized: string[];
};
// Maps parsed CSV rows to application inputs using the header row. Rows missing company or role
// are skipped (and counted). Status is coerced to a valid stage or dropped.
export function mapCsvToApplications(table: string[][]): CsvMapResult {
const [headerRow, ...dataRows] = table;
if (!headerRow) return { rows: [], skipped: 0, headers: [], recognized: [] };
const headers = headerRow.map((h) => h.trim());
const fieldFor = headers.map((h) => HEADER_ALIASES[h.toLowerCase()]);
const recognized = [...new Set(fieldFor.filter((f): f is keyof ParsedApplication => !!f))];
const rows: ParsedApplication[] = [];
let skipped = 0;
for (const raw of dataRows) {
const record: Partial<ParsedApplication> = {};
fieldFor.forEach((field, i) => {
if (!field) return;
const value = (raw[i] ?? "").trim();
if (!value) return;
if (field === "tags")
record.tags = value
.split(/[;,|]/)
.map((t) => t.trim())
.filter(Boolean);
else if (field === "status") {
const parsed = applicationStatusSchema.safeParse(value.toLowerCase());
if (parsed.success) record.status = parsed.data;
} else record[field] = value as never;
});
if (!record.company || !record.role) {
skipped++;
continue;
}
rows.push(record as ParsedApplication);
}
return { rows, skipped, headers, recognized };
}
@@ -0,0 +1,39 @@
import type { StageCount } from "./insights";
import { describe, expect, it } from "vitest";
import { computeInsights } from "./insights";
const byStage: StageCount[] = [
{ status: "saved", count: 2 },
{ status: "applied", count: 3 },
{ status: "screening", count: 2 },
{ status: "interview", count: 1 },
{ status: "offer", count: 1 },
{ status: "rejected", count: 2 },
];
describe("computeInsights", () => {
it("totals every stage including rejected", () => {
expect(computeInsights(byStage).total).toBe(11);
});
it("computes cumulative reach down the forward funnel", () => {
const { funnel } = computeInsights(byStage);
// saved reached = all forward stages = 2+3+2+1+1 = 9
expect(funnel[0]?.reached).toBe(9);
// offer reached = 1 (just the offer bucket)
expect(funnel.at(-1)?.reached).toBe(1);
});
it("derives tiles (applied past saved, interviews, offers)", () => {
const tiles = Object.fromEntries(computeInsights(byStage).tiles.map((tile) => [tile.label, tile.value]));
expect(tiles.Applied).toBe("7"); // everything past saved
expect(tiles.Interviews).toBe("2"); // interview + offer
expect(tiles.Offers).toBe("1");
});
it("handles an empty pipeline without dividing by zero", () => {
const empty = computeInsights([]);
expect(empty.total).toBe(0);
expect(empty.funnel[0]?.pct).toBe(0);
});
});
@@ -0,0 +1,83 @@
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
import { STAGES } from "@reactive-resume/schema/applications/data";
export type StageCount = { status: ApplicationStatus; count: number };
// The forward pipeline (rejected is a terminal outcome, handled separately).
const FORWARD: ApplicationStatus[] = ["saved", "applied", "screening", "interview", "offer"];
export type Insights = {
total: number;
tiles: { label: string; value: string; sub: string }[];
funnel: { label: string; color: string; count: number; reached: number; pct: number; conv: string }[];
rejected: number;
};
// Pure function of the raw per-stage counts, so it's trivially testable and shared by every
// chart in the Insights view. "reached[i]" assumes the pipeline is monotonic for currently
// active applications (an app at Interview has passed through Screening).
export function computeInsights(byStage: StageCount[]): Insights {
const counts = new Map<ApplicationStatus, number>(byStage.map((row) => [row.status, row.count]));
const at = (status: ApplicationStatus) => counts.get(status) ?? 0;
const total = byStage.reduce((sum, row) => sum + row.count, 0);
const rejected = at("rejected");
// reached[i] = active apps that got at least as far as FORWARD[i].
const reached = FORWARD.map((_, i) => FORWARD.slice(i).reduce((sum, s) => sum + at(s), 0));
const appliedOn = reached[1] ?? 0; // everything that made it past "saved"
const funnel = FORWARD.map((status, i) => {
const stage = STAGES.find((s) => s.value === status);
const reachedCount = reached[i] ?? 0;
const prev = i === 0 ? reachedCount : (reached[i - 1] ?? reachedCount);
return {
label: stage?.label ?? status,
color: stage?.color ?? "var(--muted)",
count: at(status),
reached: reachedCount,
pct: total > 0 ? Math.round((reachedCount / total) * 100) : 0,
conv: prev > 0 ? `${Math.round((reachedCount / prev) * 100)}%` : "—",
};
});
const interviews = at("interview") + at("offer");
const offers = at("offer");
const responseRate = appliedOn > 0 ? Math.round(((reached[2] ?? 0) / appliedOn) * 100) : 0;
const tiles = [
{ label: "Total applications", value: String(total), sub: "in this view" },
{ label: "Applied", value: String(appliedOn), sub: "past saved" },
{ label: "Response rate", value: `${responseRate}%`, sub: "reached screening" },
{ label: "Interviews", value: String(interviews), sub: "interview or beyond" },
{ label: "Offers", value: String(offers), sub: rejected > 0 ? `${rejected} rejected` : "so far" },
];
return { total, tiles, funnel, rejected };
}
export type TimelineBucket = { label: string; count: number };
// Bucket application dates into the last `weeks` calendar weeks (Sunday-started) so the Insights
// view can show application velocity over time — a dimension the funnel/tiles don't capture.
export function computeTimeline(dates: Date[], weeks = 8): TimelineBucket[] {
const msWeek = 7 * 86_400_000;
const startOfWeek = new Date();
startOfWeek.setHours(0, 0, 0, 0);
startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay());
const buckets = Array.from({ length: weeks }, (_, i) => {
const start = new Date(startOfWeek.getTime() - (weeks - 1 - i) * msWeek);
return { start: start.getTime(), label: `${start.getMonth() + 1}/${start.getDate()}`, count: 0 };
});
const first = buckets[0]?.start ?? 0;
for (const date of dates) {
const time = date.getTime();
if (time < first) continue;
const index = Math.min(weeks - 1, Math.floor((time - first) / msWeek));
const bucket = buckets[index];
if (bucket) bucket.count++;
}
return buckets.map(({ label, count }) => ({ label, count }));
}
@@ -0,0 +1,9 @@
import { orpc } from "@/libs/orpc/client";
// A single source of truth for the applications list query so the query key stays identical
// across the board (optimistic drag), the detail panel, and the route. We always fetch archived
// rows too and filter them per-view client-side, so "unarchive" has something to act on.
const LIST_INPUT = { includeArchived: true } as const;
export const applicationsListQueryOptions = () => orpc.applications.list.queryOptions({ input: LIST_INPUT });
export const applicationsListQueryKey = () => orpc.applications.list.queryKey({ input: LIST_INPUT });
@@ -0,0 +1,19 @@
// Deterministic accent color for a company's initials tile, shared by the board card and the
// table row so the same company reads the same everywhere.
const TILE_COLORS = [
"bg-rose-500",
"bg-orange-500",
"bg-amber-500",
"bg-emerald-500",
"bg-teal-500",
"bg-sky-500",
"bg-indigo-500",
"bg-violet-500",
"bg-fuchsia-500",
];
export function tileColor(seed: string) {
let hash = 0;
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0;
return TILE_COLORS[Math.abs(hash) % TILE_COLORS.length];
}
@@ -0,0 +1,3 @@
import type { RouterOutput } from "@/libs/orpc/client";
export type Application = RouterOutput["applications"]["list"][number];
@@ -4,16 +4,16 @@ import { CommandItem } from "@reactive-resume/ui/components/command";
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
import { BaseCommandGroup } from "../base";
const handleLocaleChange = async (value: string) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
export function LanguageCommandPage() {
const { i18n } = useLingui();
const handleLocaleChange = async (value: string) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
return (
<BaseCommandGroup page="language" heading={<Trans>Language</Trans>}>
{Object.entries(localeMap).map(([value, label]) => (
@@ -3,7 +3,7 @@
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { localeMap } from "@/libs/locale";
import { getLocaleOptions } from "./combobox";
import { getLocaleOptions } from "./locale-options";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
+7 -29
View File
@@ -1,43 +1,21 @@
import type { Locale } from "@reactive-resume/utils/locale";
import type { SingleComboboxProps } from "@/components/ui/combobox";
import { i18n } from "@lingui/core";
import { useLingui } from "@lingui/react";
import { Combobox } from "@/components/ui/combobox";
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
import { isLocale, loadLocale, setLocaleCookie } from "@/libs/locale";
import { getLocaleOptions } from "./locale-options";
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
export const getLocaleOptions = () => {
return Object.entries(localeMap).map(([value, label]) => {
const name = i18n.t(label);
return {
value: value as Locale,
label: (
<span className="flex items-center gap-x-2">
<span className="font-mono text-muted-foreground text-xs">{value}</span>
{name}
</span>
),
// Shown in the collapsed trigger (a ReactNode label would otherwise fall back to the ISO code).
textValue: name,
// Match against the translated name, the ISO code, and the untranslated English name so
// the list stays searchable regardless of the active UI locale.
keywords: [name, value.toLowerCase(), label.message].filter((keyword): keyword is string => Boolean(keyword)),
};
});
const onLocaleChange = async (value: string | null) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
export function LocaleCombobox(props: Props) {
const { i18n } = useLingui();
const onLocaleChange = async (value: string | null) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
return (
<Combobox
showClear={false}
@@ -0,0 +1,24 @@
import type { Locale } from "@reactive-resume/utils/locale";
import { i18n } from "@lingui/core";
import { localeMap } from "@/libs/locale";
export const getLocaleOptions = () => {
return Object.entries(localeMap).map(([value, label]) => {
const name = i18n.t(label);
return {
value: value as Locale,
label: (
<span className="flex items-center gap-x-2">
<span className="font-mono text-muted-foreground text-xs">{value}</span>
{name}
</span>
),
// Shown in the collapsed trigger (a ReactNode label would otherwise fall back to the ISO code).
textValue: name,
// Match against the translated name, the ISO code, and the untranslated English name so
// the list stays searchable regardless of the active UI locale.
keywords: [name, value.toLowerCase(), label.message].filter((keyword): keyword is string => Boolean(keyword)),
};
});
};
@@ -199,7 +199,12 @@ async function flushResumeSave(id: string) {
if (currentDataStillMatchesSubmission && !runtime.pendingResume) {
runtime.hasPendingLocalChanges = false;
useResumeStore.getState().replaceResumeFromServer(updated);
// The local data still equals what we just saved, so the client already holds the canonical
// data — only server-owned metadata (updatedAt, etc.) can differ. Merge just that instead of
// replacing the whole resume: a full replace swaps every nested reference (the server strips
// `undefined`s, so an equality check on its echo can't even confirm they match), which fires
// every `data` selector and remounts the entire builder tree on each save-after-typing.
useResumeStore.getState().mergeResumeMetadata(updated);
useResumeStore.getState().setSaveStatus("saved");
} else {
runtime.hasPendingLocalChanges = true;
@@ -1,6 +1,6 @@
import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from "pdfjs-dist/legacy/build/pdf.mjs";
import type { ReactNode } from "react";
import type { PreviewPageSize } from "./preview.shared";
import type { PreviewPageSize } from "./preview.shared.utils";
import {
AnnotationMode,
GlobalWorkerOptions,
@@ -9,7 +9,7 @@ import {
} from "pdfjs-dist/legacy/build/pdf.mjs";
import { useEffect, useRef, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import { DEFAULT_PDF_PAGE_SIZE, getPreviewCanvasScale, getScaledPreviewPageSize } from "./preview.shared";
import { DEFAULT_PDF_PAGE_SIZE, getPreviewCanvasScale, getScaledPreviewPageSize } from "./preview.shared.utils";
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
@@ -1,4 +1,4 @@
import type { PreviewPageSize } from "./preview.shared";
import type { PreviewPageSize } from "./preview.shared.utils";
import { getResumeThumbnailRenderSize, RESUME_THUMBNAIL_TARGET_WIDTH } from "./resume-thumbnail.shared";
const canvasToBlob = async (canvas: HTMLCanvasElement) => {
@@ -1,5 +1,6 @@
import type { CSSProperties } from "react";
import type { PreviewPageSize, ResolvedResumePreviewProps } from "./preview.shared";
import type { ResolvedResumePreviewProps } from "./preview.shared";
import type { PreviewPageSize } from "./preview.shared.utils";
import { AnimatePresence, m } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { isRTL } from "@reactive-resume/utils/locale";
@@ -7,7 +8,8 @@ import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { usePreviewPausedStore, useResumeData } from "../builder/draft";
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
import { getResumePreviewGapValue, getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
import { ResumePreviewLoader } from "./preview.shared";
import { getResumePreviewGapValue, getResumePreviewPageCount } from "./preview.shared.utils";
import { ResumeAccessibleText } from "./resume-accessible-text";
type PreviewPdf = {
@@ -6,7 +6,7 @@ import {
getPreviewCanvasScale,
getResumePreviewGapValue,
getScaledPreviewPageSize,
} from "./preview.shared";
} from "./preview.shared.utils";
describe("getScaledPreviewPageSize", () => {
it("multiplies both dimensions by the scale", () => {
@@ -3,12 +3,8 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import {
DEFAULT_PDF_PAGE_SIZE,
getResumePreviewPageCount,
getScaledPreviewPageSize,
ResumePreviewLoader,
} from "./preview.shared";
import { ResumePreviewLoader } from "./preview.shared";
import { DEFAULT_PDF_PAGE_SIZE, getResumePreviewPageCount, getScaledPreviewPageSize } from "./preview.shared.utils";
describe("ResumePreviewLoader", () => {
it("uses the same scaled page dimensions as the preview page", () => {
@@ -2,6 +2,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CSSProperties } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { DEFAULT_PDF_PAGE_SIZE, getResumePreviewGapValue, getScaledPreviewPageSize } from "./preview.shared.utils";
export type ResumePreviewProps = {
className?: string;
@@ -19,11 +20,6 @@ export type ResolvedResumePreviewProps = ResumePreviewProps & {
showPageNumbers: boolean;
};
export type PreviewPageSize = {
height: number;
width: number;
};
type ResumePreviewLoaderProps = Pick<ResumePreviewProps, "pageClassName" | "showPageNumbers"> & {
pageCount?: number;
pageGap?: CSSProperties["gap"];
@@ -31,35 +27,8 @@ type ResumePreviewLoaderProps = Pick<ResumePreviewProps, "pageClassName" | "show
pageScale?: number;
};
const PDF_PAGE_RENDER_SCALE = 4;
const MAX_PREVIEW_CANVAS_PIXELS = 16_777_216; // 4096 * 4096
export const DEFAULT_PDF_PAGE_SIZE: PreviewPageSize = {
height: 841.89,
width: 595.28,
};
// ponytail: normalizeResumePreviewProps deleted — defaults now live in ResumePreview destructuring
export const getPreviewCanvasScale = (width: number, height: number) => {
const devicePixelRatio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
const desiredScale = Math.max(PDF_PAGE_RENDER_SCALE, devicePixelRatio);
const desiredPixels = width * height * desiredScale * desiredScale;
if (desiredPixels <= MAX_PREVIEW_CANVAS_PIXELS) return desiredScale;
return Math.sqrt(MAX_PREVIEW_CANVAS_PIXELS / (width * height));
};
export const getScaledPreviewPageSize = (pageSize: PreviewPageSize, pageScale: number): PreviewPageSize => ({
height: pageSize.height * pageScale,
width: pageSize.width * pageScale,
});
export const getResumePreviewGapValue = (pageGap: CSSProperties["gap"]) =>
typeof pageGap === "number" && pageGap !== 0 ? `${pageGap}px` : pageGap;
export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1);
export function ResumePreviewLoader({
pageCount = 1,
pageClassName,
@@ -0,0 +1,35 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CSSProperties } from "react";
export type PreviewPageSize = {
height: number;
width: number;
};
const PDF_PAGE_RENDER_SCALE = 4;
const MAX_PREVIEW_CANVAS_PIXELS = 16_777_216; // 4096 * 4096
export const DEFAULT_PDF_PAGE_SIZE: PreviewPageSize = {
height: 841.89,
width: 595.28,
};
export const getPreviewCanvasScale = (width: number, height: number) => {
const devicePixelRatio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
const desiredScale = Math.max(PDF_PAGE_RENDER_SCALE, devicePixelRatio);
const desiredPixels = width * height * desiredScale * desiredScale;
if (desiredPixels <= MAX_PREVIEW_CANVAS_PIXELS) return desiredScale;
return Math.sqrt(MAX_PREVIEW_CANVAS_PIXELS / (width * height));
};
export const getScaledPreviewPageSize = (pageSize: PreviewPageSize, pageScale: number): PreviewPageSize => ({
height: pageSize.height * pageScale,
width: pageSize.width * pageScale,
});
export const getResumePreviewGapValue = (pageGap: CSSProperties["gap"]) =>
typeof pageGap === "number" && pageGap !== 0 ? `${pageGap}px` : pageGap;
export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1);
@@ -2,7 +2,8 @@ import type { ResumePreviewProps } from "./preview.shared";
import { lazy, Suspense } from "react";
import { useIsClient } from "usehooks-ts";
import { useResumeData } from "../builder/draft";
import { getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
import { ResumePreviewLoader } from "./preview.shared";
import { getResumePreviewPageCount } from "./preview.shared.utils";
const ResumePreviewClient = lazy(() =>
import("./preview.browser").then((module) => ({ default: module.ResumePreviewClient })),
+7 -7
View File
@@ -29,6 +29,13 @@ type Props = {
children: ({ session }: { session: AuthSession }) => React.ComponentProps<typeof DropdownMenuTrigger>["render"];
};
const handleLocaleChange = async (value: string) => {
if (!isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
export function UserDropdownMenu({ children }: Props) {
const isClient = useIsClient();
const router = useRouter();
@@ -41,13 +48,6 @@ export function UserDropdownMenu({ children }: Props) {
setTheme(value);
};
const handleLocaleChange = async (value: string) => {
if (!isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
const handleLogout = async () => {
const toastId = toast.loading(t`Signing out...`);

Some files were not shown because too many files have changed in this diff Show More