feat(applications): improve performance

This commit is contained in:
Amruth Pillai
2026-07-05 19:47:49 +02:00
parent 5a1cc6585c
commit 00a9721556
37 changed files with 11819 additions and 362 deletions
@@ -3,27 +3,9 @@ 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";
// Deterministic accent color for a company's logo tile (design shows colored initials tiles).
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",
];
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];
}
type Props = {
application: Application;
onClick?: () => void;
@@ -5,26 +5,26 @@ import { Trans } from "@lingui/react/macro";
import {
ArrowRightIcon,
ArrowSquareOutIcon,
FilePdfIcon,
PencilSimpleIcon,
PlusIcon,
TrashIcon,
UploadSimpleIcon,
XCircleIcon,
XIcon,
} from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useRef, useState } from "react";
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);
@@ -36,6 +36,7 @@ type Props = {
export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Props) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [note, setNote] = useState("");
const id = application?.id;
@@ -58,7 +59,6 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.campaigns.queryKey() });
if (id) void queryClient.invalidateQueries({ queryKey: orpc.applications.getById.queryKey({ input: { id } }) });
};
@@ -90,37 +90,6 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
}),
);
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadCoverLetter = useMutation(orpc.storage.uploadFile.mutationOptions({ meta: { noInvalidate: true } }));
const deleteFile = useMutation(orpc.storage.deleteFile.mutationOptions({ meta: { noInvalidate: true } }));
const onSelectCoverLetter = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !current) return;
if (file.type !== "application/pdf") {
toast.error(t`Please upload a PDF file.`);
return;
}
const toastId = toast.loading(t`Uploading cover letter…`);
uploadCoverLetter.mutate(file, {
onSuccess: ({ url }) => {
toast.dismiss(toastId);
update.mutate({ id: current.id, coverLetterUrl: url, coverLetterName: file.name });
if (fileInputRef.current) fileInputRef.current.value = "";
},
onError: () => toast.error(t`Couldn't upload the file. Please try again.`, { id: toastId }),
});
};
const removeCoverLetter = () => {
if (!current?.coverLetterUrl) return;
// Best-effort delete of the stored file; the storage route defaults a bare filename to the
// user's upload dir. Clear the fields regardless so the UI reflects the removal.
const filename = new URL(current.coverLetterUrl, window.location.origin).pathname.split("/").pop();
if (filename) deleteFile.mutate({ filename });
update.mutate({ id: current.id, coverLetterUrl: null, coverLetterName: null });
};
if (!current) return null;
const idx = stageIndex(current.status);
@@ -128,7 +97,7 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
return (
<Sheet open={!!application} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full gap-0 sm:max-w-md">
<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">
@@ -171,13 +140,12 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
</div>
</SheetHeader>
<div className="flex flex-1 flex-col gap-5 overflow-y-auto px-4 py-4">
<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()} />
<Fact label={t`Campaign`} value={current.campaign} />
</dl>
{current.sourceUrl && (
@@ -214,48 +182,44 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
</p>
)}
{current.coverLetterUrl ? (
<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={current.coverLetterUrl}
target="_blank"
rel="noreferrer"
className="min-w-0 flex-1 truncate text-sm hover:underline"
>
{current.coverLetterName || t`Cover letter`}
</a>
<button
type="button"
title={t`Remove cover letter`}
className="text-muted-foreground hover:text-destructive"
onClick={removeCoverLetter}
>
<XIcon />
</button>
</div>
) : (
<button
type="button"
disabled={uploadCoverLetter.isPending}
onClick={() => fileInputRef.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 />
{uploadCoverLetter.isPending ? <Trans>Uploading</Trans> : <Trans>Attach a cover letter (PDF)</Trans>}
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
className="hidden"
onChange={onSelectCoverLetter}
<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
@@ -310,8 +274,6 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
</Button>
</div>
</Section>
<ApplicationAiCopilot application={current} />
</div>
<div className="flex items-center gap-1 border-border border-t p-4">
@@ -338,7 +300,13 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
variant="ghost"
className="ms-auto text-destructive"
disabled={remove.isPending}
onClick={() => remove.mutate({ id: current.id })}
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>
@@ -1,8 +1,9 @@
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 } from "@phosphor-icons/react";
import { SparkleIcon, XIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
@@ -22,11 +23,10 @@ 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";
const SOURCE_OPTIONS = ["LinkedIn", "Indeed", "Company website", "Referral", "Recruiter", "Other"].map((s) => ({
value: s,
label: s,
}));
// 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: "",
@@ -36,16 +36,21 @@ const EMPTY = {
source: "",
status: "saved" as ApplicationStatus,
resumeId: "",
campaign: "",
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,
@@ -55,12 +60,14 @@ function toForm(app: Application): FormState {
source: app.source ?? "",
status: app.status,
resumeId: app.resumeId ?? "",
campaign: app.campaign ?? "",
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),
};
}
@@ -87,7 +94,7 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
const { data: resumes } = useQuery(orpc.resume.list.queryOptions());
const resumeOptions = (resumes ?? []).map((resume) => ({ value: resume.id, label: resume.name }));
const { data: campaigns } = useQuery(orpc.applications.campaigns.queryOptions());
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
@@ -95,7 +102,7 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.campaigns.queryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() });
if (application) {
void queryClient.invalidateQueries({
queryKey: orpc.applications.getById.queryKey({ input: { id: application.id } }),
@@ -153,14 +160,18 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
status: form.status,
location: form.location.trim() || null,
salary: form.salary.trim() || null,
source: form.source || null,
source: form.source.trim() || null,
resumeId: form.resumeId || null,
campaign: form.campaign.trim() || 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);
@@ -168,7 +179,7 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full gap-0 sm:max-w-md">
<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>
@@ -180,7 +191,7 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
</SheetDescription>
</SheetHeader>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
<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">
@@ -218,7 +229,17 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
<div className="grid grid-cols-2 gap-3">
<Field label={t`Location`}>
<Input value={form.location} onChange={(event) => set("location", event.target.value)} />
<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)} />
@@ -227,13 +248,17 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
<div className="grid grid-cols-2 gap-3">
<Field label={t`Source`}>
<Combobox
className="w-full"
value={form.source || null}
options={SOURCE_OPTIONS}
placeholder={t`Select…`}
onValueChange={(value) => set("source", value ?? "")}
<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
@@ -245,30 +270,39 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
</Field>
</div>
<Field label={t`Resume used`}>
<Combobox
className="w-full"
value={form.resumeId || null}
options={resumeOptions}
placeholder={t`Link a Reactive Resume…`}
showClear
emptyMessage={t`No resumes yet.`}
onValueChange={(value) => set("resumeId", value ?? "")}
{/* 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`Campaign`}>
<Input
value={form.campaign}
list="application-campaigns"
placeholder={t`e.g. Spring 2026 · New Grad`}
onChange={(event) => set("campaign", event.target.value)}
/>
<datalist id="application-campaigns">
{(campaigns ?? []).map((campaign) => (
<option key={campaign.name} value={campaign.name} />
))}
</datalist>
<Field label={t`Tags`}>
<TagsField value={form.tags} suggestions={allTags ?? []} onChange={(tags) => set("tags", tags)} />
</Field>
<div className="grid grid-cols-2 gap-3">
@@ -323,3 +357,67 @@ function Field({ label, required, children }: { label: string; required?: boolea
</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,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} />
</>
);
}
@@ -50,7 +50,6 @@ export function ImportApplicationsSheet({ open, onOpenChange }: Props) {
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.campaigns.queryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() });
toast.success(t`Imported ${result.imported} application(s).`);
setText("");
@@ -68,7 +67,7 @@ export function ImportApplicationsSheet({ open, onOpenChange }: Props) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full gap-0 sm:max-w-md">
<SheetContent side="right" className="w-full gap-0 data-[side=right]:sm:max-w-lg">
<SheetHeader>
<SheetTitle>
<Trans>Import from CSV</Trans>
@@ -1,17 +1,24 @@
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 { useRef } from "react";
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 } from "../insights";
import { computeInsights, computeTimeline } from "../insights";
type Props = { campaign?: string };
export function ApplicationInsights({ applications }: { applications: Application[] }) {
const { data } = useQuery(orpc.applications.stats.queryOptions({}));
export function ApplicationInsights({ campaign }: Props) {
const { data } = useQuery(orpc.applications.stats.queryOptions({ input: campaign ? { campaign } : {} }));
// 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" />;
@@ -29,11 +36,7 @@ export function ApplicationInsights({ campaign }: Props) {
return (
<div className="flex max-w-4xl flex-col gap-4 overflow-y-auto pb-6">
<p className="text-muted-foreground text-xs">
{campaign ? (
<Trans>Pipeline health for campaign {campaign}</Trans>
) : (
<Trans>Pipeline health across all applications</Trans>
)}
<Trans>Pipeline health across all applications</Trans>
</p>
{/* stat tiles */}
@@ -50,32 +53,27 @@ export function ApplicationInsights({ campaign }: Props) {
<PipelineFlow insights={insights} />
<div className="grid gap-4 lg:grid-cols-2">
{/* funnel */}
{/* application velocity over time */}
<div className="rounded-xl border border-border p-5">
<h3 className="font-semibold text-sm">
<Trans>Pipeline funnel</Trans>
<Trans>Applications over time</Trans>
</h3>
<p className="mt-0.5 text-muted-foreground text-xs">
<Trans>How far applications get, and stage-to-stage conversion</Trans>
<Trans>Applications sent per week (last 8 weeks)</Trans>
</p>
<div className="mt-4 flex flex-col gap-3.5">
{insights.funnel.map((stage) => (
<div key={stage.label}>
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className="flex items-center gap-2 font-medium">
<span className="size-2 rounded-sm" style={{ background: stage.color }} />
{stage.label}
<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>
<span className="text-muted-foreground">
<b className="text-foreground">{stage.reached}</b> · {stage.conv}
</span>
</div>
<div className="h-2.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full"
style={{ width: `${Math.max(stage.pct, 2)}%`, background: stage.color }}
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>
@@ -115,38 +113,102 @@ export function ApplicationInsights({ campaign }: Props) {
);
}
// A funnel-flow diagram (the shareable snapshot). Hand-drawn SVG with inline fills so it can be
// exported to PNG without any library.
// 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 = 720;
const H = 220;
const W = 800;
const H = 340;
const padX = 40;
const midY = 190;
const maxBarH = 150;
const barW = 30;
const barW = 34;
const n = insights.funnel.length;
const slotW = W / n;
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, 3);
const x = slotW * i + slotW / 2 - barW / 2;
const yTop = 30 + (maxBarH - h) / 2;
return { ...f, x, yTop, h, cx: x + barW / 2 };
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 = () => {
const exportPng = async () => {
const svg = svgRef.current;
if (!svg) return;
const xml = new XMLSerializer().serializeToString(svg);
// 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 * 2;
canvas.height = H * 2;
canvas.width = W * scale;
canvas.height = H * scale;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = "#ffffff";
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");
@@ -161,53 +223,102 @@ function PipelineFlow({ insights }: { insights: ReturnType<typeof computeInsight
return (
<div className="rounded-xl border border-border p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-semibold text-sm">
<Trans>Where your applications went</Trans>
</h3>
<p className="mt-0.5 text-muted-foreground text-xs">
<Trans>Full-funnel snapshot a shareable picture of the whole search</Trans>
</p>
</div>
<Button size="sm" variant="outline" onClick={exportPng}>
<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" role="img" aria-label={t`Pipeline flow`}>
{/* connecting bands */}
<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.x + barW;
const x2 = next.x;
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${(x1 + x2) / 2},${bar.yTop} ${(x1 + x2) / 2},${next.yTop} ${x2},${next.yTop} L${x2},${next.yTop + next.h} C${(x1 + x2) / 2},${next.yTop + next.h} ${(x1 + x2) / 2},${bar.yTop + bar.h} ${x1},${bar.yTop + bar.h} Z`}
fill={next.color}
opacity={0.18}
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) => (
{bars.map((bar, i) => (
<g key={`bar-${bar.label}`}>
<rect x={bar.x} y={bar.yTop} width={barW} height={bar.h} rx={5} fill={bar.color} />
<text x={bar.cx} y={bar.yTop - 8} textAnchor="middle" fontSize={13} fontWeight={700} fill="#333">
<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 - 22} textAnchor="middle" fontSize={11} fill="#888">
<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>
))}
{insights.rejected > 0 && (
<text x={W - 8} y={18} textAnchor="end" fontSize={11} fill="#c0392b">
{insights.rejected} rejected
</text>
)}
{/* 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>
);
@@ -59,7 +59,6 @@ export type ParsedApplication = {
location?: string;
salary?: string;
source?: string;
campaign?: string;
notes?: string;
sourceUrl?: string;
tags?: string[];
@@ -81,7 +80,6 @@ const HEADER_ALIASES: Record<string, keyof ParsedApplication> = {
"salary range": "salary",
compensation: "salary",
source: "source",
campaign: "campaign",
notes: "notes",
note: "notes",
url: "sourceUrl",
@@ -54,3 +54,30 @@ export function computeInsights(byStage: StageCount[]): Insights {
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,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];
}
@@ -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;
@@ -3,13 +3,12 @@ import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function AwardsSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.awards;
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.awards);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof awardItemSchema>[]) => {
@@ -3,13 +3,12 @@ import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function CertificationsSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.certifications;
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.certifications);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof certificationItemSchema>[]) => {
@@ -32,7 +32,7 @@ import {
import { stripHtml } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { useDialogStore } from "@/dialogs/store";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useConfirm } from "@/hooks/use-confirm";
import { getSectionTitle } from "@/libs/resume/section";
import { SectionBase } from "../shared/section-base";
@@ -122,8 +122,7 @@ function getItemSubtitle(type: CustomSectionType, item: CustomSectionItemType):
}
export function CustomSectionBuilder() {
const resume = useCurrentResume();
const customSections = resume.data.customSections;
const customSections = useCurrentBuilderResumeSelector((resume) => resume.data.customSections);
return (
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
@@ -3,13 +3,12 @@ import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function EducationSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.education;
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.education);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof educationItemSchema>[]) => {
@@ -4,13 +4,12 @@ import { plural } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function ExperienceSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.experience;
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.experience);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof experienceItemSchema>[]) => {
@@ -3,13 +3,12 @@ import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function InterestsSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.interests;
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.interests);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof interestItemSchema>[]) => {
@@ -3,13 +3,12 @@ import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function LanguagesSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.languages;
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.languages);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof languageItemSchema>[]) => {
@@ -36,7 +36,7 @@ import {
import { Slider } from "@reactive-resume/ui/components/slider";
import "react-easy-crop/react-easy-crop.css";
import { ColorPicker } from "@/components/input/color-picker";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
import { getReadableErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
@@ -447,8 +447,7 @@ function PictureSectionForm() {
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const resume = useCurrentResume();
const picture = resume.data.picture;
const picture = useCurrentBuilderResumeSelector((resume) => resume.data.picture);
const normalizedPictureUrl = normalizePictureUrl(picture.url, appOrigin);
const updateResumeData = useUpdateResumeData();
@@ -3,13 +3,12 @@ import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function ProfilesSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.profiles;
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.profiles);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof profileItemSchema>[]) => {
@@ -3,13 +3,17 @@ import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
const buildSubtitle = (item: z.infer<typeof projectItemSchema>) => {
const parts = [item.period, item.website.label].filter((part) => part && part.trim().length > 0);
return parts.length > 0 ? parts.join(" • ") : undefined;
};
export function ProjectsSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.projects;
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.projects);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof projectItemSchema>[]) => {
@@ -18,11 +22,6 @@ export function ProjectsSectionBuilder() {
});
};
const buildSubtitle = (item: z.infer<typeof projectItemSchema>) => {
const parts = [item.period, item.website.label].filter((part) => part && part.trim().length > 0);
return parts.length > 0 ? parts.join(" • ") : undefined;
};
return (
<SectionBase type="projects" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
@@ -3,13 +3,12 @@ import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function PublicationsSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.publications;
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.publications);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof publicationItemSchema>[]) => {
@@ -3,13 +3,12 @@ import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function ReferencesSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.references;
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.references);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof referenceItemSchema>[]) => {
@@ -3,13 +3,12 @@ import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function SkillsSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.skills;
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.skills);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof skillItemSchema>[]) => {
@@ -1,10 +1,9 @@
import { RichInput } from "@/components/input/rich-input";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
export function SummarySectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.summary;
const section = useCurrentBuilderResumeSelector((resume) => resume.data.summary);
const updateResumeData = useUpdateResumeData();
const onChange = (value: string) => {
@@ -3,13 +3,12 @@ import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function VolunteerSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.volunteer;
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.volunteer);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof volunteerItemSchema>[]) => {
@@ -7,7 +7,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@r
import { Button } from "@reactive-resume/ui/components/button";
import { cn } from "@reactive-resume/utils/style";
import { IconPicker } from "@/components/input/icon-picker";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { getSectionIcon, getSectionTitle } from "@/libs/resume/section";
import { useSectionStore } from "../../../-store/section";
import { SectionDropdownMenu } from "./section-menu";
@@ -17,11 +17,13 @@ type Props = React.ComponentProps<typeof AccordionContent> & {
};
export function SectionBase({ type, className, ...props }: Props) {
const resume = useCurrentResume();
const updateResumeData = useUpdateResumeData();
const data = resume.data;
const section =
type === "basics"
// Subscribe to only this section's slice, not the whole resume. Otherwise editing any field
// (which replaces the resume reference) re-renders all ~15 section wrappers on every keystroke.
// Immer keeps untouched slices reference-stable, so Zustand bails out of the unrelated sections.
const section = useCurrentBuilderResumeSelector((resume) => {
const data = resume.data;
return type === "basics"
? data.basics
: type === "summary"
? data.summary
@@ -30,6 +32,7 @@ export function SectionBase({ type, className, ...props }: Props) {
: type === "custom"
? data.customSections
: data.sections[type];
});
const isHidden = "hidden" in section && section.hidden;
const hasSectionIcon = !["picture", "basics", "custom"].includes(type);
@@ -7,6 +7,7 @@ import {
BriefcaseIcon,
ChartBarIcon,
DownloadSimpleIcon,
FunnelIcon,
KanbanIcon,
MagnifyingGlassIcon,
PlusIcon,
@@ -19,6 +20,7 @@ import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@reactive-resume/ui/components/input-group";
import { Label } from "@reactive-resume/ui/components/label";
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Separator } from "@reactive-resume/ui/components/separator";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { Combobox } from "@/components/ui/combobox";
@@ -32,15 +34,24 @@ import { applicationsListQueryOptions } from "@/features/applications/queries";
import { orpc } from "@/libs/orpc/client";
import { DashboardHeader } from "../-components/header";
const SORT_OPTIONS = [
{ value: "updated", label: msg`Last updated` },
{ value: "applied", label: msg`Date applied` },
{ value: "company", label: msg`Company AZ` },
{ value: "role", label: msg`Role AZ` },
] as const;
type SortKey = (typeof SORT_OPTIONS)[number]["value"];
const searchSchema = z.object({
search: z.string().default(""),
view: z.enum(["board", "table", "insights"]).default("board"),
tags: z.array(z.string()).default([]),
campaign: z.string().default(""),
sort: z.enum(["updated", "applied", "company", "role"]).default("updated"),
archived: z.boolean().default(false),
});
type Search = z.output<typeof searchSchema>;
const defaultSearch: Search = { search: "", view: "board", tags: [], campaign: "", archived: false };
const defaultSearch: Search = { search: "", view: "board", tags: [], sort: "updated", archived: false };
export const Route = createFileRoute("/dashboard/applications/")({
component: RouteComponent,
@@ -50,7 +61,7 @@ export const Route = createFileRoute("/dashboard/applications/")({
function RouteComponent() {
const { i18n } = useLingui();
const { search, view, tags, campaign, archived } = Route.useSearch();
const { search, view, tags, sort, archived } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const [addOpen, setAddOpen] = useState(false);
@@ -66,17 +77,23 @@ function RouteComponent() {
const { data: applications } = useQuery(applicationsListQueryOptions());
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
const { data: campaigns } = useQuery(orpc.applications.campaigns.queryOptions());
// Board & table hide archived; campaign/tag/search filters are applied client-side.
// Board & table hide archived; tag/search filters + sort are applied client-side.
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return (applications ?? [])
const rows = (applications ?? [])
.filter((app) => archived || !app.archived)
.filter((app) => !campaign || app.campaign === campaign)
.filter((app) => tags.length === 0 || tags.every((tag: string) => app.tags.includes(tag)))
.filter((app) => !query || app.company.toLowerCase().includes(query) || app.role.toLowerCase().includes(query));
}, [applications, search, campaign, tags, archived]);
const compare: Record<SortKey, (a: Application, b: Application) => number> = {
updated: (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
applied: (a, b) => new Date(b.appliedAt).getTime() - new Date(a.appliedAt).getTime(),
company: (a, b) => a.company.localeCompare(b.company),
role: (a, b) => a.role.localeCompare(b.role),
};
return rows.sort(compare[sort as SortKey]);
}, [applications, search, tags, sort, archived]);
const archivedCount = (applications ?? []).filter((app) => app.archived).length;
@@ -111,8 +128,9 @@ function RouteComponent() {
<EmptyState onAdd={() => setAddOpen(true)} onImport={() => setImportOpen(true)} />
) : (
<>
<div className="flex flex-wrap items-center gap-3">
<InputGroup className="w-full sm:w-56">
{/* One row: search grows, filters stay fixed, icon-only view switcher on the right. */}
<div className="flex items-center gap-2">
<InputGroup className="min-w-24 max-w-72 flex-1">
<InputGroupAddon align="inline-start">
<MagnifyingGlassIcon />
</InputGroupAddon>
@@ -123,37 +141,33 @@ function RouteComponent() {
/>
</InputGroup>
{(campaigns?.length ?? 0) > 0 && (
<div className="flex items-center gap-2">
<Label className="text-muted-foreground text-xs">
<Trans>Campaign</Trans>
</Label>
<Combobox
className="w-44"
value={campaign || null}
showClear
placeholder={t`All`}
options={(campaigns ?? []).map((c) => ({ value: c.name, label: `${c.name} (${c.count})` }))}
onValueChange={(value) => setSearch({ campaign: value ?? "" })}
/>
</div>
)}
{/* Desktop: filters inline. Mobile: collapsed into the Filters popover below. */}
{(allTags?.length ?? 0) > 0 && (
<Combobox
multiple
className="w-44"
className="w-40 min-w-0 shrink max-sm:hidden"
value={tags}
placeholder={t`Filter tags`}
placeholder={t`Filter by tags`}
options={(allTags ?? []).map((tag) => ({ value: tag, label: tag }))}
onValueChange={(value) => setSearch({ tags: value ?? [] })}
/>
)}
{view !== "insights" && (
<Combobox
className="w-40 min-w-0 shrink max-sm:hidden"
value={sort}
placeholder={t`Sort by…`}
options={SORT_OPTIONS.map((option) => ({ value: option.value, label: i18n.t(option.label) }))}
onValueChange={(value) => value && setSearch({ sort: value as SortKey })}
/>
)}
{archivedCount > 0 && view !== "insights" && (
<Button
size="sm"
variant={archived ? "secondary" : "outline"}
className="shrink-0 max-sm:hidden"
onClick={() => setSearch({ archived: !archived })}
>
<ArchiveIcon />
@@ -161,31 +175,89 @@ function RouteComponent() {
</Button>
)}
<Tabs className="ms-auto" value={view}>
{/* Mobile-only: one button holds every filter so the row never overflows on a phone. */}
{view !== "insights" && (
<Popover>
<PopoverTrigger
render={
<Button size="icon-sm" variant="outline" className="relative shrink-0 sm:hidden">
<FunnelIcon />
{(tags.length > 0 || archived) && (
<span className="absolute end-1 top-1 size-1.5 rounded-full bg-primary" />
)}
</Button>
}
/>
<PopoverContent align="end" className="w-64 p-3">
{(allTags?.length ?? 0) > 0 && (
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">
<Trans>Filter by tags</Trans>
</Label>
<Combobox
multiple
className="w-full"
value={tags}
placeholder={t`Any tag`}
options={(allTags ?? []).map((tag) => ({ value: tag, label: tag }))}
onValueChange={(value) => setSearch({ tags: value ?? [] })}
/>
</div>
)}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">
<Trans>Sort by</Trans>
</Label>
<Combobox
className="w-full"
value={sort}
options={SORT_OPTIONS.map((option) => ({ value: option.value, label: i18n.t(option.label) }))}
onValueChange={(value) => value && setSearch({ sort: value as SortKey })}
/>
</div>
{archivedCount > 0 && (
<Button
size="sm"
variant={archived ? "secondary" : "outline"}
className="w-full"
onClick={() => setSearch({ archived: !archived })}
>
<ArchiveIcon />
<Trans>Archived</Trans> ({archivedCount})
</Button>
)}
</PopoverContent>
</Popover>
)}
<Tabs className="ms-auto shrink-0" value={view}>
<TabsList>
<TabsTrigger
value="board"
title={i18n.t(msg`Board`)}
nativeButton={false}
render={<Link to="." search={(p: Search) => ({ ...p, view: "board" })} />}
>
<KanbanIcon />
<span className="max-sm:sr-only">{i18n.t(msg`Board`)}</span>
<span className="sr-only">{i18n.t(msg`Board`)}</span>
</TabsTrigger>
<TabsTrigger
value="table"
title={i18n.t(msg`Table`)}
nativeButton={false}
render={<Link to="." search={(p: Search) => ({ ...p, view: "table" })} />}
>
<RowsIcon />
<span className="max-sm:sr-only">{i18n.t(msg`Table`)}</span>
<span className="sr-only">{i18n.t(msg`Table`)}</span>
</TabsTrigger>
<TabsTrigger
value="insights"
title={i18n.t(msg`Insights`)}
nativeButton={false}
render={<Link to="." search={(p: Search) => ({ ...p, view: "insights" })} />}
>
<ChartBarIcon />
<span className="max-sm:sr-only">{i18n.t(msg`Insights`)}</span>
<span className="sr-only">{i18n.t(msg`Insights`)}</span>
</TabsTrigger>
</TabsList>
</Tabs>
@@ -200,7 +272,7 @@ function RouteComponent() {
<Button
size="sm"
variant="outline"
onClick={() => setSearch({ search: "", tags: [], campaign: "", archived: false })}
onClick={() => setSearch({ search: "", tags: [], archived: false })}
>
<Trans>Clear filters</Trans>
</Button>
@@ -213,7 +285,7 @@ function RouteComponent() {
{view === "table" && (
<ApplicationTable applications={filtered} onOpen={setSelected} onEdit={setEditing} />
)}
{view === "insights" && <ApplicationInsights campaign={campaign || undefined} />}
{view === "insights" && <ApplicationInsights applications={applications ?? []} />}
</>
)}
</div>
@@ -0,0 +1,2 @@
ALTER TABLE "application" ADD COLUMN "resume_file_url" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "resume_file_name" text;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
ALTER TABLE "application" DROP COLUMN "campaign";
File diff suppressed because it is too large Load Diff
+8 -10
View File
@@ -22,10 +22,14 @@ const applicationSchema = createSelectSchema(schema.application, {
jobDescription: z.string().nullable(),
matchScore: z.number().int().min(0).max(100).nullable(),
aiMetadata: aiMetadataSchema.nullable(),
campaign: z.string().trim().nullable(),
notes: z.string().nullable(),
// Rendered as an <a href>; only same-origin storage URLs are ever stored. Constrain to
// http(s)/relative so a hand-crafted `update` can't smuggle a `javascript:` href.
resumeFileUrl: z
.string()
.refine((value) => /^(https?:\/\/|\/)/.test(value), "Resume file URL must be http(s) or a relative path.")
.nullable(),
resumeFileName: z.string().nullable(),
coverLetterUrl: z
.string()
.refine((value) => /^(https?:\/\/|\/)/.test(value), "Cover letter URL must be http(s) or a relative path.")
@@ -52,8 +56,9 @@ const editableSchema = applicationSchema.pick({
source: true,
sourceUrl: true,
jobDescription: true,
campaign: true,
notes: true,
resumeFileUrl: true,
resumeFileName: true,
coverLetterUrl: true,
coverLetterName: true,
followUpAt: true,
@@ -74,7 +79,6 @@ export const applicationDto = {
input: z
.object({
status: applicationStatusSchema.optional(),
campaign: z.string().optional(),
tags: z.array(z.string()).optional(),
includeArchived: z.boolean().optional().default(false),
})
@@ -135,7 +139,7 @@ export const applicationDto = {
// Aggregates for the Insights view. Everything else (funnel, sankey, tiles) is derived
// client-side from these raw counts via computeInsights().
stats: {
input: z.object({ campaign: z.string().optional() }).optional(),
input: z.void(),
output: z.object({
total: z.number(),
byStage: z.array(z.object({ status: applicationStatusSchema, count: z.number() })),
@@ -143,12 +147,6 @@ export const applicationDto = {
}),
},
// Distinct campaign names for the sidebar/filter (Phase 2 uses the same shape).
campaigns: {
input: z.void(),
output: z.array(z.object({ name: z.string(), count: z.number() })),
},
tags: {
input: z.void(),
output: z.array(z.string()),
+7 -1
View File
@@ -61,9 +61,15 @@ async function fetchJobPostingText(url: string): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
// Job boards 403 obvious bot user-agents, so present as a normal browser.
const response = await fetch(url, {
signal: controller.signal,
headers: { "user-agent": "ReactiveResumeBot/1.0" },
headers: {
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "en-US,en;q=0.9",
},
});
if (!response.ok)
throw new ORPCError("BAD_REQUEST", { message: `Couldn't fetch the posting (HTTP ${response.status}).` });
+2 -22
View File
@@ -21,7 +21,6 @@ export const crudRouter = {
return applicationService.list({
userId: context.user.id,
...(input.status ? { status: input.status } : {}),
...(input.campaign ? { campaign: input.campaign } : {}),
...(input.tags ? { tags: input.tags } : {}),
includeArchived: input.includeArchived,
});
@@ -174,32 +173,13 @@ export const crudRouter = {
tags: ["Applications"],
operationId: "getApplicationStats",
summary: "Application pipeline stats",
description:
"Returns aggregate counts (per stage, per source) for the Insights view. Optionally scoped to a campaign. Requires authentication.",
description: "Returns aggregate counts (per stage, per source) for the Insights view. Requires authentication.",
successDescription: "Aggregate application counts.",
})
.input(applicationDto.stats.input)
.output(applicationDto.stats.output)
.handler(async ({ input, context }) => {
return applicationService.stats({
userId: context.user.id,
...(input?.campaign ? { campaign: input.campaign } : {}),
});
}),
campaigns: protectedProcedure
.route({
method: "GET",
path: "/applications/campaigns",
tags: ["Applications"],
operationId: "listApplicationCampaigns",
summary: "List campaigns",
description: "Returns distinct campaign names with their application counts. Requires authentication.",
successDescription: "Distinct campaigns with counts.",
})
.output(applicationDto.campaigns.output)
.handler(async ({ context }) => {
return applicationService.campaigns({ userId: context.user.id });
return applicationService.stats({ userId: context.user.id });
}),
tags: protectedProcedure
@@ -12,7 +12,6 @@ export const applicationsRouter = {
bulkUpdate: crudRouter.bulkUpdate,
bulkDelete: crudRouter.bulkDelete,
stats: crudRouter.stats,
campaigns: crudRouter.campaigns,
tags: crudRouter.tags,
ai: aiRouter,
};
@@ -24,8 +24,9 @@ type EditableFields = {
source?: string | null | undefined;
sourceUrl?: string | null | undefined;
jobDescription?: string | null | undefined;
campaign?: string | null | undefined;
notes?: string | null | undefined;
resumeFileUrl?: string | null | undefined;
resumeFileName?: string | null | undefined;
coverLetterUrl?: string | null | undefined;
coverLetterName?: string | null | undefined;
followUpAt?: Date | null | undefined;
@@ -51,13 +52,7 @@ const stripUserId = <T extends { userId: string }>(row: T) => {
};
export const applicationService = {
list: async (input: {
userId: string;
status?: ApplicationStatus;
campaign?: string;
tags?: string[];
includeArchived?: boolean;
}) => {
list: async (input: { userId: string; status?: ApplicationStatus; tags?: string[]; includeArchived?: boolean }) => {
const rows = await db
.select()
.from(schema.application)
@@ -65,7 +60,6 @@ export const applicationService = {
and(
eq(schema.application.userId, input.userId),
input.status ? eq(schema.application.status, input.status) : undefined,
input.campaign ? eq(schema.application.campaign, input.campaign) : undefined,
input.tags && input.tags.length > 0 ? arrayContains(schema.application.tags, input.tags) : undefined,
),
)
@@ -241,12 +235,8 @@ export const applicationService = {
},
// Raw counts for Insights; funnel/sankey/tiles are derived client-side from these.
stats: async (input: { userId: string; campaign?: string }) => {
const scope = and(
eq(schema.application.userId, input.userId),
eq(schema.application.archived, false),
input.campaign ? eq(schema.application.campaign, input.campaign) : undefined,
);
stats: async (input: { userId: string }) => {
const scope = and(eq(schema.application.userId, input.userId), eq(schema.application.archived, false));
const byStage = await db
.select({ status: schema.application.status, count: sql<number>`count(*)::int` })
@@ -271,16 +261,6 @@ export const applicationService = {
};
},
campaigns: async (input: { userId: string }) => {
const rows = await db
.select({ name: schema.application.campaign, count: sql<number>`count(*)::int` })
.from(schema.application)
.where(and(eq(schema.application.userId, input.userId), eq(schema.application.archived, false)))
.groupBy(schema.application.campaign);
return rows.filter((row): row is { name: string; count: number } => !!row.name).sort((a, b) => b.count - a.count);
},
listTags: async (input: { userId: string }) => {
const rows = await db
.select({ tag: sql<string>`distinct unnest(${schema.application.tags})` })
+4 -2
View File
@@ -35,9 +35,11 @@ export const application = pg.pgTable(
jobDescription: pg.text("job_description"),
matchScore: pg.integer("match_score"),
aiMetadata: pg.jsonb("ai_metadata").$type<AiMetadata>(),
// Reserves the deferred campaigns feature; no management UI this pass.
campaign: pg.text("campaign"),
notes: pg.text("notes"),
// An uploaded resume file (PDF) sent with this application, distinct from the live
// resumeId link. Stored as the storage URL + original filename for display.
resumeFileUrl: pg.text("resume_file_url"),
resumeFileName: pg.text("resume_file_name"),
// A cover letter sent with this application (PDF/doc uploaded to storage). Stored as the
// key returned by the storage upload plus the original filename for display.
coverLetterUrl: pg.text("cover_letter_url"),