feat(web): applications as a grouped list, a rebuilt detail sheet and an add dialog

- The page: CSV import/export behind one icon, Add application, a dismissible
  nudge for the application waiting longest without a reply (10+ days),
  List · Board · Insights · Calendar, search across role, company, contacts
  and tags, and Show closed.
- List (the default): grouped by stage in the order that needs you first,
  with collapsible groups, the next step (warn when overdue), what was sent,
  sorting from the headers and row checkboxes for bulk moves, tags, closing
  and deleting. Phones get two-line rows and no board.
- Board: a column per stage; drops and Move to… show the same toast.
- Detail sheet (480 px, full screen on phones): the stage stepper with Move to
  next, the next step (the next interview or follow-up) with Edit and Add to
  calendar (.ics), what was sent (opening the version sent, read-only, in
  History), Tailor a resume and Write a letter, editable salary and source,
  contacts, tags, autosaved notes and the activity timeline. Close
  application… takes a reason; Delete is in ⋯ and asks first.
- Add dialog: paste a link or posting; its role, company and requirements are
  read into editable fields and the posting is saved with the application.
  Add, or Add and tailor a resume.
- Insights: how far applications get (from their stage history), how many
  heard back and how fast, and tailored against base resumes, above the
  existing charts.
- CSV import shows how columns were matched before saving and lets you
  download the rows it skips; export adds the closed reason.
- The builder opens History on a version from ?version=.
This commit is contained in:
Amruth Pillai
2026-09-28 22:14:20 +02:00
parent ce0b4c606a
commit 462db6011a
43 changed files with 3458 additions and 1917 deletions
@@ -0,0 +1,298 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query";
import { useEffect, useId, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { useDialogStore } from "@/dialogs/store";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { getStageLabel } from "../stages";
import { useInvalidateApplications } from "../use-application-actions";
type Parsed = RouterOutput["applications"]["ai"]["parsePosting"];
type Stage = "saved" | "applied" | "interview";
const STAGES: readonly Stage[] = ["saved", "applied", "interview"];
const MAX_POSTING_CHARS = 20_000;
// Reading waits for a pause in typing, so it starts once the paste has landed.
const READ_DELAY_MS = 600;
const isLink = (value: string) => /^https?:\/\/\S+$/i.test(value.trim());
type AddApplicationDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdded: (id: string) => void;
};
/**
* Add an application from a pasted link or posting. The posting is read (the page's own job data, or the AI
* provider when one is set up) into role and company, which stay editable; the posting itself is saved with the
* application for Check, the assistant and letters. Add, or Add and tailor a resume.
*/
export function AddApplicationDialog({ open, onOpenChange, onAdded }: AddApplicationDialogProps) {
// A fresh form for every open, kept in place while the dialog animates closed.
const [instance, setInstance] = useState(0);
const [wasOpen, setWasOpen] = useState(open);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setInstance((count) => count + 1);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[560px]">
<AddApplicationForm key={instance} onClose={() => onOpenChange(false)} onAdded={onAdded} />
</DialogContent>
</Dialog>
);
}
function AddApplicationForm({ onClose, onAdded }: { onClose: () => void; onAdded: (id: string) => void }) {
const id = useId();
const [input, setInput] = useState("");
const [role, setRole] = useState("");
const [company, setCompany] = useState("");
const [stage, setStage] = useState<Stage>("applied");
const [parsed, setParsed] = useState<Parsed | null>(null);
const { hasUsableProvider } = useHasUsableAiProvider();
const invalidate = useInvalidateApplications();
const openDialog = useDialogStore((state) => state.openDialog);
const read = useMutation(orpc.applications.ai.parsePosting.mutationOptions());
const { mutate: readPosting, reset: resetRead } = read;
const create = useMutation(orpc.applications.create.mutationOptions());
const text = input.trim();
const link = isLink(text);
// A link can always be read (for the page's own job data); pasted text needs the AI provider.
const readable = text.length > 8 && (link || hasUsableProvider);
useEffect(() => {
setParsed(null);
resetRead();
if (!readable) return;
const timeout = window.setTimeout(() => {
readPosting(
{ input: text },
{
onSuccess: (result) => {
setParsed(result);
setRole((current) => current || result.role);
setCompany((current) => current || result.company);
},
},
);
}, READ_DELAY_MS);
return () => window.clearTimeout(timeout);
}, [text, readable, readPosting, resetRead]);
const ready = role.trim().length > 0 && company.trim().length > 0 && !read.isPending && !create.isPending;
const add = async (tailor: boolean) => {
if (!ready) return;
try {
const applicationId = await create.mutateAsync({
company: company.trim(),
role: role.trim(),
status: stage,
...(parsed?.location ? { location: parsed.location } : {}),
...(parsed?.salary ? { salary: parsed.salary } : {}),
...(parsed?.requirements.length ? { requirements: parsed.requirements } : {}),
...(link ? { sourceUrl: text } : {}),
// The posting's text: read from the page for a link, or what was pasted.
...(parsed?.jobDescription
? { jobDescription: parsed.jobDescription }
: !link && text
? { jobDescription: text.slice(0, MAX_POSTING_CHARS) }
: {}),
});
invalidate();
toast.add({ description: t`Added ${role.trim()} at ${company.trim()}` });
onClose();
onAdded(applicationId);
if (tailor) openDialog("document.new", { step: "copy", applicationId });
} catch (error) {
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Couldn't add the application.` }),
});
}
};
return (
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
void add(true);
}}
>
<DialogHeader>
<DialogTitle>
<Trans>Add an application</Trans>
</DialogTitle>
<DialogDescription className="sr-only">
<Trans>Paste a job link or posting, then check the role and company.</Trans>
</DialogDescription>
</DialogHeader>
<div className="grid gap-1.5">
<Label htmlFor={`${id}-posting`}>
<Trans>Job link or posting text</Trans>
</Label>
<Textarea
id={`${id}-posting`}
rows={4}
value={input}
maxLength={MAX_POSTING_CHARS}
placeholder="https://…"
onChange={(event) => setInput(event.target.value)}
autoFocus
/>
<ReadStatus
text={text}
link={link}
readable={readable}
pending={read.isPending}
error={read.error}
parsed={parsed}
ai={hasUsableProvider}
/>
</div>
<div className="grid grid-cols-2 gap-3 max-sm:grid-cols-1">
<div className="grid gap-1.5">
<Label htmlFor={`${id}-role`}>
<Trans>Role</Trans>
</Label>
<Input id={`${id}-role`} value={role} onChange={(event) => setRole(event.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${id}-company`}>
<Trans>Company</Trans>
</Label>
<Input id={`${id}-company`} value={company} onChange={(event) => setCompany(event.target.value)} />
</div>
</div>
<div className="grid gap-1.5">
<span id={`${id}-stage`} className="font-medium text-sm">
<Trans>Stage</Trans>
</span>
<SegmentedControl
aria-labelledby={`${id}-stage`}
value={stage}
onValueChange={(value) => setStage(value as Stage)}
className="w-fit"
>
{STAGES.map((value) => (
<SegmentedControlItem key={value} value={value}>
{getStageLabel(value)}
</SegmentedControlItem>
))}
</SegmentedControl>
</div>
<DialogFooter>
<Button type="button" variant="secondary" disabled={!ready} onClick={() => void add(false)}>
<Trans>Add</Trans>
</Button>
<Button type="submit" disabled={!ready}>
<Trans>Add and tailor a resume</Trans>
</Button>
</DialogFooter>
</form>
);
}
type ReadStatusProps = {
text: string;
link: boolean;
readable: boolean;
pending: boolean;
error: unknown;
parsed: Parsed | null;
ai: boolean;
};
/** What reading the posting found, or why the fields need filling in by hand. */
function ReadStatus({ text, link, readable, pending, error, parsed, ai }: ReadStatusProps) {
const base = "flex items-start gap-1.5 text-xs leading-[17px]";
if (!text) {
return (
<p className={cn(base, "text-ink-3")}>
<Trans>We read the page and save the posting for Check, the assistant and your letter.</Trans>
</p>
);
}
if (pending) {
return (
<p className={cn(base, "text-ink-2")} role="status">
{link ? <Trans>Reading the link…</Trans> : <Trans>Reading the posting…</Trans>}
</p>
);
}
if (error) {
return (
<p className={cn(base, "text-warn-text")} role="alert">
<Icon name="error" size={16} className="shrink-0" />
{getOrpcErrorMessage(error, {
byCode: { POSTING_UNREADABLE: t`That link couldn't be read. Paste the posting text instead.` },
fallback: t`The posting couldn't be read. Fill in the role and company.`,
})}
</p>
);
}
if (parsed?.filledBy === "ai") {
return (
<p className={cn(base, "text-accent-text")} role="status">
<Icon name="check_circle" size={16} className="shrink-0" />
<Plural
value={parsed.requirements.length}
one="Found role, company, location and # requirement. Saved with the application."
other="Found role, company, location and # requirements. Saved with the application."
/>
</p>
);
}
if (parsed?.filledBy === "page") {
return (
<p className={cn(base, "text-accent-text")} role="status">
<Icon name="check_circle" size={16} className="shrink-0" />
<Trans>Found the role and company on the page. The posting is saved with the application.</Trans>
</p>
);
}
return (
<p className={cn(base, "text-ink-3")}>
{link && readable ? (
<Trans>The page had no job details to read. Fill in the role and company.</Trans>
) : ai ? (
<Trans>Fill in the role and company. The posting is saved with the application.</Trans>
) : (
<Trans>Fill in the role and company. Connect an AI provider in settings to read them from the posting.</Trans>
)}
</p>
);
}
@@ -1,16 +1,6 @@
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 { STAGES } from "@reactive-resume/schema/applications/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
@@ -22,56 +12,26 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { toast } from "@reactive-resume/ui/components/toast";
import { Icon } from "@reactive-resume/ui/components/icon";
import { cn } from "@reactive-resume/utils/style";
import { useConfirm } from "@/hooks/use-confirm";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
import { CLOSED_REASONS, getClosedReasonLabel, getStageColor, getStageLabel, PIPELINE } from "../stages";
import { useApplicationActions } from "../use-application-actions";
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.
// Keep pointer and clicks from reaching the card, which would start a drag or open the detail sheet. React portals
// bubble synthetic events through the React tree, so a menu item's click would otherwise reach the card.
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();
type ApplicationActionsMenuProps = { application: Application; className?: string };
/** A card's ⋯ menu, the keyboard alternative to dragging: Move to…, Close…, and Delete after asking. */
export function ApplicationActionsMenu({ application, className }: ApplicationActionsMenuProps) {
const { moveTo, close, remove } = useApplicationActions();
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.add({ type: "error", description: t`Something went wrong. Please try again.` }),
}),
);
const remove = useMutation(
orpc.applications.delete.mutationOptions({
onSuccess: () => {
invalidate();
toast.add({ type: "success", description: t`Application deleted.` });
},
onError: () => toast.add({ type: "error", description: 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.`,
description: t`“${application.role} · ${application.company}” and its timeline are deleted permanently. This can't be undone.`,
confirmText: t`Delete`,
});
if (confirmed) remove.mutate({ id: application.id });
@@ -83,56 +43,60 @@ export function ApplicationActionsMenu({ application, onEdit, showOnHover, class
<DropdownMenuTrigger
render={
<Button
size="icon-sm"
size="icon-xs"
variant="ghost"
aria-label={t`Application actions`}
aria-label={t`Options for ${application.role} at ${application.company}`}
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>
className="text-ink-3"
/>
}
/>
<DropdownMenuContent align="end" className="w-44" onClick={stop}>
<DropdownMenuItem onClick={() => onEdit(application)}>
<PencilSimpleIcon />
<Trans>Edit</Trans>
</DropdownMenuItem>
>
<Icon name="more_horiz" size={18} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" onClick={stop}>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ArrowRightIcon />
<Trans>Move to</Trans>
<Icon name="arrow_forward" size={18} />
<Trans>Move to…</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{STAGES.map((stage) => (
{PIPELINE.map((status) => (
<DropdownMenuItem
key={stage.value}
disabled={stage.value === application.status}
onClick={() => update.mutate({ id: application.id, status: stage.value })}
key={status}
disabled={status === application.status}
onClick={() => moveTo(application, status)}
>
<span className="size-2 rounded-sm" style={{ background: stage.color }} />
{stage.label}
<span
aria-hidden="true"
className="size-2 rounded-full"
style={{ background: getStageColor(status) }}
/>
{getStageLabel(status)}
</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>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Icon name="check" size={18} />
<Trans>Close…</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{CLOSED_REASONS.map((reason) => (
<DropdownMenuItem key={reason} onClick={() => close(application, reason)}>
{getClosedReasonLabel(reason)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<TrashIcon />
<Trans>Delete</Trans>
<Icon name="delete" size={18} />
<Trans>Delete…</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -35,6 +35,11 @@ const application: Application = {
salary: null,
status: "saved",
archived: false,
closedReason: null,
coverLetterId: null,
sentResumeVersionId: null,
sentCheckScore: null,
requirements: [],
resumeId: null,
source: null,
sourceUrl: null,
@@ -1,95 +1,56 @@
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 { useLingui } from "@lingui/react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { cn } from "@reactive-resume/utils/style";
import { describeNextStep, getNextStep } from "../next-step";
import { ApplicationActionsMenu } from "./application-actions-menu";
type Props = {
type ApplicationCardProps = {
application: Application;
onClick?: () => void;
onEdit?: (application: Application) => void;
className?: string;
/** The ⋯ menu (Move to…, Close…, Delete); cards dragged in the overlay go without. */
withMenu?: boolean;
dragging?: boolean;
};
export function ApplicationCard({ application, onClick, onEdit, className, dragging }: Props) {
const followUp = application.followUpAt && !application.archived;
/** A board card: the company's initial, role and company, and the next step (warn when it's overdue). */
export function ApplicationCard({ application, onClick, withMenu = false, dragging = false }: ApplicationCardProps) {
const { i18n } = useLingui();
const next = describeNextStep(getNextStep(application), application, i18n.locale);
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 && "scale-[1.02] shadow-lg",
className,
"group relative grid gap-2 rounded-[10px] border border-line bg-surface p-3 shadow-e1 transition-colors duration-quick hover:border-line-2",
dragging && "rotate-1 shadow-e3",
)}
>
{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-7 shrink-0 items-center justify-center rounded-[7px] bg-sunken font-semibold text-ink-2 text-xs",
)}
<span
aria-hidden="true"
className="grid size-7 shrink-0 place-items-center rounded-[7px] bg-sunken font-semibold text-ink-2 text-xs"
>
{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-warn ring-2 ring-warn/25", onEdit && "me-6")}
/>
)}
{application.company.slice(0, 1).toUpperCase()}
</span>
<button
type="button"
onClick={onClick}
className="grid min-w-0 flex-1 text-start after:absolute after:inset-0 after:rounded-[10px]"
>
<span className="truncate font-semibold text-sm">{application.role}</span>
<span className="truncate text-ink-3 text-xs">{application.company}</span>
</button>
{withMenu && <ApplicationActionsMenu application={application} className="relative z-10 -me-1.5 -mt-1.5" />}
</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>
)}
<span
className={cn(
"flex items-center gap-1.5 text-xs",
next.tone === "warn" ? "text-warn-text" : next.tone === "muted" ? "text-ink-3" : "text-ink-2",
)}
>
<Icon name={next.icon} size={15} className="shrink-0" />
<span className="truncate">{next.title}</span>
</span>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -12,156 +12,114 @@ import {
useSensors,
} from "@dnd-kit/core";
import { t } from "@lingui/core/macro";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { toast } from "@reactive-resume/ui/components/toast";
import { useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
import { getStageColor, getStageLabel, PIPELINE } from "../stages";
import { useApplicationActions } from "../use-application-actions";
import { ApplicationCard } from "./application-card";
type Props = {
type BoardProps = {
applications: Application[];
showClosed: boolean;
onOpen: (application: Application) => void;
onEdit: (application: Application) => void;
};
export function ApplicationBoard({ applications, onOpen, onEdit }: Props) {
const queryClient = useQueryClient();
/**
* A column per stage (Closed only when shown). Dropping a card on a column moves it, with the same toast as the
* other ways to change stage; each card's menu has Move to… for the keyboard. Desktop and tablet only.
*/
export function ApplicationBoard({ applications, showClosed, onOpen }: BoardProps) {
const { moveTo } = useApplicationActions();
const [activeId, setActiveId] = useState<string | null>(null);
// A small activation distance so a click still opens the detail panel instead of starting a drag.
// A small activation distance so a click still opens the detail sheet instead of starting a drag.
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const stages: ApplicationStatus[] = showClosed ? [...PIPELINE, "closed"] : [...PIPELINE];
const listKey = applicationsListQueryKey();
const byStage = new Map<ApplicationStatus, Application[]>(stages.map((stage) => [stage, []]));
for (const application of applications) byStage.get(application.status)?.push(application);
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.add({ type: "error", description: 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 active = activeId ? applications.find((application) => application.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 });
const target = event.over?.id as ApplicationStatus | undefined;
const application = applications.find((item) => item.id === event.active.id);
if (!target || !application || application.status === target) return;
moveTo(application, 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 className="flex h-full min-h-0 gap-3 overflow-x-auto pb-4">
{stages.map((stage) => (
<Column key={stage} stage={stage} applications={byStage.get(stage) ?? []} onOpen={onOpen} />
))}
</div>
{/* No drop animation: the optimistic move lands after an await, so the default would fly the card back. */}
<DragOverlay dropAnimation={null}>
{activeApp ? <ApplicationCard application={activeApp} dragging /> : null}
{active ? <ApplicationCard application={active} 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.
// Cap the cards rendered per column so a stage with hundreds of applications doesn't mount hundreds of draggable
// nodes at once; the rest show in batches.
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);
type ColumnProps = {
stage: ApplicationStatus;
applications: Application[];
onOpen: (application: Application) => void;
};
function Column({ stage, applications, onOpen }: ColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id: stage });
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} />
<section
aria-label={getStageLabel(stage)}
className={cn(
"flex w-[272px] shrink-0 flex-col rounded-xl border bg-sunken transition-colors duration-quick",
isOver ? "border-accent bg-accent-soft" : "border-transparent",
)}
>
<h3 className="flex items-center gap-2 px-3 py-2.5 font-semibold text-sm">
<span aria-hidden="true" className="size-2 rounded-full" style={{ background: getStageColor(stage) }} />
{getStageLabel(stage)}
<span className="font-mono font-normal text-ink-3 text-xs">{applications.length}</span>
</h3>
<div ref={setNodeRef} className="flex min-h-24 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
{shown.map((application) => (
<DraggableCard key={application.id} application={application} onOpen={() => onOpen(application)} />
))}
{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"
onClick={() => setVisible((count) => count + COLUMN_PAGE_SIZE)}
className="rounded-lg border border-line border-dashed py-2 text-ink-3 text-xs hover:bg-hover"
>
{t`Show ${Math.min(remaining, COLUMN_PAGE_SIZE)} more`}
</button>
)}
</div>
</div>
</section>
);
}
function DraggableCard({
application,
onOpen,
onEdit,
}: {
application: Application;
onOpen: () => void;
onEdit: (application: Application) => void;
}) {
function DraggableCard({ application, onOpen }: { application: Application; onOpen: () => 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 ref={setNodeRef} {...attributes} {...listeners} className={cn(isDragging && "opacity-40")}>
<ApplicationCard application={application} onClick={onOpen} withMenu />
</div>
);
}
@@ -0,0 +1,249 @@
import type { ApplicationTimelineEntry, InterviewTimelineEntry } from "@reactive-resume/schema/applications/data";
import type { Application } from "../../types";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { useConfirm } from "@/hooks/use-confirm";
import { orpc } from "@/libs/orpc/client";
import { interviewKindOf } from "../../interviews";
import { getStageColor, getStageLabel } from "../../stages";
import { useInvalidateApplications } from "../../use-application-actions";
const byNewest = (a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) =>
new Date(b.at).getTime() - new Date(a.at).getTime();
const dateInputValue = (value: Date | string) => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
};
type ActivityProps = {
application: Application;
onOpenInterview: (interview: InterviewTimelineEntry) => void;
};
/**
* The timeline: stage changes, notes and interviews, newest first, with a note field on top. Each row's ⋯ edits its
* date (and a note's text) or deletes it; the entry that anchors the current stage can't be deleted.
*/
export function Activity({ application, onOpenInterview }: ActivityProps) {
const { i18n } = useLingui();
const invalidate = useInvalidateApplications();
const confirm = useConfirm();
const [note, setNote] = useState("");
const [editing, setEditing] = useState<{ entry: ApplicationTimelineEntry; date: string; text: string } | null>(null);
const onError = () => toast.add({ type: "error", description: t`Couldn't update the timeline. Try again.` });
const addNote = useMutation({
...orpc.applications.addNote.mutationOptions(),
onSuccess: () => invalidate(application.id),
onError,
});
const updateEntry = useMutation({
...orpc.applications.updateTimelineEntry.mutationOptions(),
onSuccess: () => {
invalidate(application.id);
setEditing(null);
},
onError,
});
const deleteEntry = useMutation({
...orpc.applications.deleteTimelineEntry.mutationOptions(),
onSuccess: () => invalidate(application.id),
onError,
});
const sorted = [...application.activity].sort(byNewest);
const anchorId = sorted.find((entry) => entry.type === "stage" && entry.stage === application.status)?.id;
const formatDate = (value: Date | string, withTime: boolean) =>
new Date(value).toLocaleDateString(i18n.locale, {
month: "short",
day: "numeric",
// Stage and note entries are whole days, stored at noon UTC.
...(withTime ? { hour: "2-digit", minute: "2-digit" } : { timeZone: "UTC" }),
});
const describe = (entry: ApplicationTimelineEntry) => {
if (entry.type === "stage")
return { dot: getStageColor(entry.stage), text: t`Moved to ${getStageLabel(entry.stage)}` };
if (entry.type === "note") return { dot: "var(--ink-3)", text: entry.text };
const kind = interviewKindOf(entry.kind);
return { dot: kind?.color ?? "var(--ink-3)", text: t`${kind?.label ?? entry.kind} interview` };
};
const submit = () => {
const text = note.trim();
if (!text || addNote.isPending) return;
addNote.mutate({ id: application.id, text }, { onSuccess: () => setNote("") });
};
return (
<section aria-labelledby="application-activity" className="grid gap-2">
<h3 id="application-activity" className="font-semibold text-ink-3 text-xs uppercase">
<Trans>Activity</Trans>
</h3>
<form
className="flex gap-1.5"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
<Input
aria-label={t`Add a note`}
placeholder={t`Add a note…`}
value={note}
disabled={addNote.isPending}
onChange={(event) => setNote(event.target.value)}
/>
<Button type="submit" variant="secondary" disabled={!note.trim() || addNote.isPending}>
<Trans>Add</Trans>
</Button>
</form>
<ol className="grid">
{sorted.map((entry) => {
const { dot, text } = describe(entry);
return (
<li key={entry.id} className="group flex items-start gap-2.5 py-1.5 text-sm">
<span aria-hidden="true" className="mt-1.5 size-2 shrink-0 rounded-full" style={{ background: dot }} />
{entry.type === "interview" ? (
<button
type="button"
onClick={() => onOpenInterview(entry)}
className="min-w-0 flex-1 text-start hover:underline"
>
{text}
{entry.location ? <span className="text-ink-3"> · {entry.location}</span> : null}
</button>
) : (
<span className="min-w-0 flex-1 whitespace-pre-wrap break-words">{text}</span>
)}
<span className="shrink-0 text-ink-3 text-xs leading-5">
{formatDate(entry.at, entry.type === "interview")}
</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon-xs"
variant="ghost"
aria-label={t`Options for this entry`}
className="-my-1 text-ink-3 opacity-0 focus-visible:opacity-100 group-hover:opacity-100 data-[popup-open]:opacity-100"
/>
}
>
<Icon name="more_horiz" size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{entry.type === "interview" ? (
<DropdownMenuItem onClick={() => onOpenInterview(entry)}>
<Trans>Edit interview…</Trans>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() =>
setEditing({
entry,
date: dateInputValue(entry.at),
text: entry.type === "note" ? entry.text : "",
})
}
>
<Trans>Edit…</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem
variant="destructive"
disabled={entry.id === anchorId}
onClick={async () => {
const confirmed = await confirm(t`Delete this entry?`, {
description: t`It's removed from the timeline for good.`,
confirmText: t`Delete`,
});
if (confirmed) deleteEntry.mutate({ id: application.id, entryId: entry.id });
}}
>
<Trans>Delete…</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
);
})}
</ol>
<Dialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
{editing?.entry.type === "note" ? <Trans>Edit note</Trans> : <Trans>Edit date</Trans>}
</DialogTitle>
<DialogDescription className="sr-only">
<Trans>Change this timeline entry.</Trans>
</DialogDescription>
</DialogHeader>
{editing && (
<form
id="timeline-entry-form"
className="grid gap-3"
onSubmit={(event) => {
event.preventDefault();
updateEntry.mutate({
id: application.id,
entryId: editing.entry.id,
date: editing.date,
...(editing.entry.type === "note" && editing.text.trim() ? { text: editing.text.trim() } : {}),
});
}}
>
{editing.entry.type === "note" && (
<Textarea
aria-label={t`Note`}
rows={3}
value={editing.text}
onChange={(event) => setEditing({ ...editing, text: event.target.value })}
/>
)}
<Input
type="date"
aria-label={t`Date`}
value={editing.date}
onChange={(event) => setEditing({ ...editing, date: event.target.value })}
/>
</form>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setEditing(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" form="timeline-entry-form" disabled={!editing?.date || updateEntry.isPending}>
<Trans>Save</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
@@ -0,0 +1,70 @@
import type { ApplicationClosedReason } from "@reactive-resume/schema/applications/data";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useId, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { RadioGroup, RadioGroupItem } from "@reactive-resume/ui/components/radio-group";
import { CLOSED_REASONS, getClosedReasonLabel } from "../../stages";
type CloseDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onClose: (reason: ApplicationClosedReason) => void;
};
/** "Close this application": one reason, kept for Insights. It replaces Reject, Archive and Delete buttons. */
export function CloseDialog({ open, onOpenChange, onClose }: CloseDialogProps) {
const id = useId();
const [reason, setReason] = useState<ApplicationClosedReason>("not-selected");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
<Trans>Close this application</Trans>
</DialogTitle>
<DialogDescription>
<Trans>The reason is kept for Insights. Linked documents aren't touched.</Trans>
</DialogDescription>
</DialogHeader>
<RadioGroup
aria-label={t`Reason`}
value={reason}
onValueChange={(value) => setReason(value as ApplicationClosedReason)}
className="grid gap-1"
>
{CLOSED_REASONS.map((value) => (
<div key={value} className="flex h-10 items-center gap-2.5 rounded-lg px-2 hover:bg-hover">
<RadioGroupItem id={`${id}-${value}`} value={value} />
<label htmlFor={`${id}-${value}`} className="flex-1 cursor-pointer text-sm">
{getClosedReasonLabel(value)}
</label>
</div>
))}
</RadioGroup>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={() => {
onClose(reason);
onOpenChange(false);
}}
>
<Trans>Close application</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,150 @@
import type { Contact } from "@reactive-resume/schema/applications/data";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useId, useState } from "react";
import { contactSchema } from "@reactive-resume/schema/applications/data";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
const EMPTY_CONTACT = { name: "", role: "", type: "", email: "", phone: "" };
type ContactsProps = { contacts: Contact[]; disabled: boolean; onChange: (contacts: Contact[]) => void };
/** The Contact cell: the primary contact (the first), and the full list, with add and remove, in a popover. */
export function Contacts({ contacts, disabled, onChange }: ContactsProps) {
const [primary, ...others] = contacts;
return (
<Popover>
<PopoverTrigger className="grid min-w-0 rounded-md text-start hover:underline">
<span className="truncate font-medium text-sm">{primary ? primary.name : "—"}</span>
{others.length > 0 && <span className="text-ink-3 text-xs">{t`and ${others.length} more`}</span>}
</PopoverTrigger>
<PopoverContent align="start" className="grid w-80 gap-3 p-3">
<h3 className="font-semibold text-sm">
<Trans>Contacts</Trans>
</h3>
<ContactList contacts={contacts} disabled={disabled} onChange={onChange} />
</PopoverContent>
</Popover>
);
}
function ContactList({ contacts, disabled, onChange }: ContactsProps) {
const id = useId();
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState(EMPTY_CONTACT);
const [error, setError] = useState("");
const add = () => {
if (!draft.name.trim()) return;
// Checked with the API's schema here, so a mistyped email is caught before the draft is thrown away.
const parsed = contactSchema.safeParse(draft);
if (!parsed.success) return setError(t`Enter a valid email address.`);
onChange([...contacts, parsed.data]);
setDraft(EMPTY_CONTACT);
setError("");
setAdding(false);
};
const field = (key: keyof typeof EMPTY_CONTACT, label: string, type = "text") => (
<Input
aria-label={label}
placeholder={label}
type={type}
value={draft[key]}
onChange={(event) => {
setError("");
setDraft((current) => ({ ...current, [key]: event.target.value }));
}}
/>
);
return (
<div className="grid gap-2">
{contacts.length === 0 && !adding && (
<p className="text-ink-3 text-sm">
<Trans>No contacts yet.</Trans>
</p>
)}
{contacts.map((contact, index) => (
<div key={`${contact.name}-${index}`} className="flex items-start gap-2.5 text-sm">
<div className="grid min-w-0 flex-1">
<span className="truncate font-medium">
{contact.name}
{contact.type && <span className="font-normal text-ink-3"> · {contact.type}</span>}
</span>
{contact.role && <span className="truncate text-ink-3 text-xs">{contact.role}</span>}
{contact.email && (
<a href={`mailto:${contact.email}`} className="truncate text-accent-text text-xs hover:underline">
{contact.email}
</a>
)}
{contact.phone && (
<a href={`tel:${contact.phone}`} className="truncate text-accent-text text-xs hover:underline">
{contact.phone}
</a>
)}
</div>
<Button
size="icon-xs"
variant="ghost"
disabled={disabled}
aria-label={t`Remove ${contact.name}`}
onClick={() => onChange(contacts.filter((_, i) => i !== index))}
>
<Icon name="close" size={16} />
</Button>
</div>
))}
{adding ? (
<form
className="grid gap-2 rounded-lg border border-line p-2.5"
onSubmit={(event) => {
event.preventDefault();
add();
}}
>
{field("name", t`Name`)}
<div className="grid grid-cols-2 gap-2">
{field("role", t`Role (optional)`)}
<Input
aria-label={t`Label`}
placeholder={t`Label, e.g. Recruiter`}
list={`${id}-types`}
value={draft.type}
onChange={(event) => setDraft((current) => ({ ...current, type: event.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-2">
{field("email", t`Email (optional)`, "email")}
{field("phone", t`Phone (optional)`, "tel")}
</div>
<datalist id={`${id}-types`}>
<option value={t`Recruiter`} />
<option value={t`Hiring manager`} />
<option value={t`Referral`} />
<option value={t`Interviewer`} />
</datalist>
{error && <p className="text-danger-text text-xs">{error}</p>}
<div className="flex justify-end gap-1.5">
<Button type="button" size="sm" variant="ghost" onClick={() => setAdding(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" size="sm" disabled={!draft.name.trim() || disabled}>
<Trans>Add</Trans>
</Button>
</div>
</form>
) : (
<Button size="sm" variant="ghost" className="w-fit" disabled={disabled} onClick={() => setAdding(true)}>
<Icon name="add" size={16} />
<Trans>Add contact</Trans>
</Button>
)}
</div>
);
}
@@ -0,0 +1,202 @@
import type { InterviewTimelineEntry } from "@reactive-resume/schema/applications/data";
import type { Application } from "../../types";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query";
import { useId, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor } from "@reactive-resume/utils/file";
import { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { buildIcs } from "../../ics";
import { dayKey } from "../../interviews";
import { describeNextStep, getNextStep } from "../../next-step";
import { useInvalidateApplications } from "../../use-application-actions";
type NextStepCardProps = {
application: Application;
onScheduleInterview: (interview: InterviewTimelineEntry | null) => void;
};
/**
* NEXT STEP: what the application needs, with Edit (schedule an interview or set the follow-up). Dated steps can go
* to a calendar as an .ics file. Overdue steps are in warn.
*/
export function NextStepCard({ application, onScheduleInterview }: NextStepCardProps) {
const { i18n } = useLingui();
const [followUpOpen, setFollowUpOpen] = useState(false);
const step = getNextStep(application);
const text = describeNextStep(step, application, i18n.locale);
const addToCalendar = () => {
if (step.kind !== "interview" && step.kind !== "follow-up") return;
const start = step.at;
const minutes = step.kind === "interview" ? step.interview.durationMinutes : 30;
const ics = buildIcs({
uid: step.kind === "interview" ? step.interview.id : `follow-up-${application.id}`,
title: `${text.title} · ${application.role}, ${application.company}`,
start,
end: new Date(start.getTime() + minutes * 60_000),
...(step.kind === "interview" && step.interview.location ? { location: step.interview.location } : {}),
...(step.kind === "interview" && step.interview.notes ? { description: step.interview.notes } : {}),
});
const blob = new Blob([ics], { type: "text/calendar" });
downloadWithAnchor(blob, `${application.company}-${dayKey(start)}.ics`.replace(/[^\w.-]+/g, "-"));
};
return (
<section aria-labelledby="application-next-step" className="grid gap-2">
<h3 id="application-next-step" className="font-semibold text-ink-3 text-xs uppercase">
<Trans>Next step</Trans>
</h3>
<div
className={cn(
"grid gap-2 rounded-xl border p-3",
text.tone === "warn" ? "border-warn bg-warn-soft" : "border-line",
)}
>
<div className="flex items-start gap-2.5">
<Icon
name={text.icon}
className={cn("mt-0.5 shrink-0", text.tone === "warn" ? "text-warn-text" : "text-ink-2")}
/>
<div className="grid min-w-0 flex-1">
<strong className="font-semibold text-sm">{text.title}</strong>
{text.sub && <span className="text-ink-2 text-xs leading-[17px]">{text.sub}</span>}
</div>
{step.kind !== "closed" && (
<DropdownMenu>
<DropdownMenuTrigger render={<Button size="sm" variant="secondary" />}>
<Trans>Edit</Trans>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{step.kind === "interview" && (
<DropdownMenuItem onClick={() => onScheduleInterview(step.interview)}>
<Trans>Edit this interview…</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onScheduleInterview(null)}>
<Trans>Schedule an interview…</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFollowUpOpen(true)}>
{application.followUpAt ? <Trans>Change the follow-up…</Trans> : <Trans>Set a follow-up…</Trans>}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{(step.kind === "interview" || step.kind === "follow-up") && (
<Button size="sm" variant="ghost" className="ms-7 w-fit" onClick={addToCalendar}>
<Icon name="calendar_month" size={16} />
<Trans>Add to calendar</Trans>
</Button>
)}
</div>
<FollowUpDialog application={application} open={followUpOpen} onOpenChange={setFollowUpOpen} />
</section>
);
}
type FollowUpDialogProps = { application: Application; open: boolean; onOpenChange: (open: boolean) => void };
/** The follow-up: a date and a note, or cleared. */
function FollowUpDialog({ application, open, onOpenChange }: FollowUpDialogProps) {
const id = useId();
const invalidate = useInvalidateApplications();
const [date, setDate] = useState("");
const [note, setNote] = useState("");
const update = useMutation({
...orpc.applications.update.mutationOptions(),
onSuccess: () => {
invalidate(application.id);
onOpenChange(false);
},
onError: () => toast.add({ type: "error", description: t`Couldn't save the follow-up.` }),
});
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (next) {
setDate(application.followUpAt ? dayKey(new Date(application.followUpAt)) : "");
setNote(application.followUpNote ?? "");
}
onOpenChange(next);
}}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
<Trans>Follow-up</Trans>
</DialogTitle>
<DialogDescription>
<Trans>A reminder to check in, shown as the next step until you do.</Trans>
</DialogDescription>
</DialogHeader>
<form
id={`${id}-form`}
className="grid gap-3"
onSubmit={(event) => {
event.preventDefault();
// A date at 9:00 local time, so it reads as that day everywhere.
const at = date ? new Date(`${date}T09:00`) : null;
update.mutate({ id: application.id, followUpAt: at, followUpNote: note.trim() || null });
}}
>
<div className="grid gap-1.5">
<Label htmlFor={`${id}-date`}>
<Trans>Date</Trans>
</Label>
<Input id={`${id}-date`} type="date" value={date} onChange={(event) => setDate(event.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${id}-note`}>
<Trans>What to do</Trans>
</Label>
<Input
id={`${id}-note`}
value={note}
placeholder={t`Email the recruiter`}
onChange={(event) => setNote(event.target.value)}
/>
</div>
</form>
<DialogFooter>
{application.followUpAt && (
<Button
variant="ghost"
className="me-auto"
onClick={() => update.mutate({ id: application.id, followUpAt: null, followUpNote: null })}
>
<Trans>Clear</Trans>
</Button>
)}
<Button type="submit" form={`${id}-form`} disabled={!date || update.isPending}>
<Trans>Save</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,201 @@
import type { Application } from "../../types";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { toast } from "@reactive-resume/ui/components/toast";
import { useDialogStore } from "@/dialogs/store";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { PIPELINE } from "../../stages";
import { useInvalidateApplications } from "../../use-application-actions";
import { FileAttachmentField } from "../file-attachment-field";
const escapeHtml = (text: string) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
/** When the application was sent: its first stage at Applied or beyond. */
function sentOn(application: Application) {
const applied = PIPELINE.indexOf("applied");
const entry = [...application.activity]
.sort((a, b) => new Date(a.at).getTime() - new Date(b.at).getTime())
.find((item) => item.type === "stage" && PIPELINE.indexOf(item.stage) >= applied);
return new Date(entry?.at ?? application.appliedAt);
}
type SentDocumentsProps = { application: Application; disabled: boolean };
/**
* WHAT YOU SENT: the linked resume (opening the version that was sent, read-only, with a way back to the latest) and
* the linked letter. Without them: Tailor a resume and Write a letter. Uploaded PDFs stay under "Attach a file".
*/
export function SentDocuments({ application, disabled }: SentDocumentsProps) {
const { i18n } = useLingui();
const navigate = useNavigate();
const invalidate = useInvalidateApplications();
const openDialog = useDialogStore((state) => state.openDialog);
const { data: documents } = useQuery(orpc.documents.list.queryOptions({ input: { trashed: false } }));
const [attaching, setAttaching] = useState(Boolean(application.resumeFileUrl || application.coverLetterUrl));
const update = useMutation({
...orpc.applications.update.mutationOptions(),
onSuccess: () => invalidate(application.id),
onError: (error) =>
toast.add({ type: "error", description: getOrpcErrorMessage(error, { fallback: t`Couldn't save.` }) }),
});
const createLetter = useMutation(orpc.coverLetters.create.mutationOptions());
const resume = documents?.find((document) => document.type === "resume" && document.id === application.resumeId);
const letter = documents?.find((document) => document.type === "letter" && document.id === application.coverLetterId);
const date = sentOn(application).toLocaleDateString(i18n.locale, { month: "short", day: "numeric" });
const writeLetter = async () => {
try {
const created = await createLetter.mutateAsync({
name: t`Cover letter — ${application.company}`.slice(0, 100),
recipient: `<p>${escapeHtml(t`Hiring team, ${application.company}`)}</p>`,
applicationId: application.id,
...(application.resumeId ? { resumeId: application.resumeId } : {}),
});
await update.mutateAsync({ id: application.id, coverLetterId: created.id });
toast.add({ description: t`Letter created with the recipient filled in` });
void navigate({ to: "/dashboard", search: { letter: created.id } });
} catch (error) {
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Couldn't create the letter.` }),
});
}
};
return (
<section aria-labelledby="application-sent" className="grid gap-2">
<h3 id="application-sent" className="font-semibold text-ink-3 text-xs uppercase">
<Trans>What you sent</Trans>
</h3>
{application.resumeId && (
<div className="flex items-center gap-2.5 rounded-xl border border-line p-3">
<Icon name="description" className="shrink-0 text-ink-2" />
<div className="grid min-w-0 flex-1">
<span className="truncate font-medium text-sm">{resume?.name ?? t`Linked resume`}</span>
<span className="text-ink-3 text-xs">
{application.sentResumeVersionId ? (
application.sentCheckScore !== null ? (
<Trans>
Version sent {date} · Check {application.sentCheckScore}
</Trans>
) : (
<Trans>Version sent {date}</Trans>
)
) : (
<Trans>Linked · not sent yet</Trans>
)}
</span>
</div>
<Link
to="/builder/$resumeId"
params={{ resumeId: application.resumeId }}
search={application.sentResumeVersionId ? { version: application.sentResumeVersionId } : {}}
className={buttonVariants({ size: "sm", variant: "secondary" })}
>
<Trans>Open</Trans>
</Link>
</div>
)}
{application.coverLetterId && (
<div className="flex items-center gap-2.5 rounded-xl border border-line p-3">
<Icon name="mail" className="shrink-0 text-ink-2" />
<div className="grid min-w-0 flex-1">
<span className="truncate font-medium text-sm">{letter?.name ?? t`Cover letter`}</span>
<span className="text-ink-3 text-xs">
<Trans>Cover letter</Trans>
</span>
</div>
<Link
to="/dashboard"
search={{ letter: application.coverLetterId }}
className={buttonVariants({ size: "sm", variant: "secondary" })}
>
<Trans>Open</Trans>
</Link>
</div>
)}
{(!application.resumeId || !application.coverLetterId) && (
<div className="flex flex-wrap gap-1.5">
{!application.resumeId && (
<Button
size="sm"
disabled={disabled}
onClick={() => openDialog("document.new", { step: "copy", applicationId: application.id })}
>
<Icon name="content_copy" size={16} />
<Trans>Tailor a resume</Trans>
</Button>
)}
{!application.coverLetterId && (
<Button
size="sm"
variant="secondary"
disabled={disabled || createLetter.isPending}
onClick={() => void writeLetter()}
>
<Icon name="mail" size={16} />
<Trans>Write a letter</Trans>
</Button>
)}
</div>
)}
{attaching ? (
<div className="grid gap-1.5">
<FileAttachmentField
value={
application.resumeFileUrl
? { url: application.resumeFileUrl, name: application.resumeFileName || t`Resume file` }
: null
}
attachLabel={t`Attach a resume file (PDF)`}
disabled={disabled || update.isPending}
onChange={(value) =>
update.mutate({
id: application.id,
resumeFileUrl: value?.url ?? null,
resumeFileName: value?.name ?? null,
})
}
/>
<FileAttachmentField
value={
application.coverLetterUrl
? { url: application.coverLetterUrl, name: application.coverLetterName || t`Cover letter file` }
: null
}
attachLabel={t`Attach a cover letter file (PDF)`}
disabled={disabled || update.isPending}
onChange={(value) =>
update.mutate({
id: application.id,
coverLetterUrl: value?.url ?? null,
coverLetterName: value?.name ?? null,
})
}
/>
</div>
) : (
<button
type="button"
onClick={() => setAttaching(true)}
className="w-fit text-ink-3 text-xs underline underline-offset-2 hover:text-ink-2"
>
<Trans>Attach a file instead</Trans>
</button>
)}
</section>
);
}
@@ -66,7 +66,7 @@ export function ExportApplicationsSheet({ open, onOpenChange, applications, filt
onValueChange={(value) => value && setScope(value)}
options={[
{ value: "filtered", label: t`Current filters` },
{ value: "all", label: t`All applications (including archived)` },
{ value: "all", label: t`All applications (including closed)` },
]}
/>
</div>
@@ -1,11 +1,12 @@
import type { CsvField } from "../csv";
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 { Plural, Trans } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query";
import { useMemo, useRef, useState } from "react";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Label } from "@reactive-resume/ui/components/label";
import { NativeSelect } from "@reactive-resume/ui/components/native-select";
import {
Sheet,
SheetContent,
@@ -16,53 +17,87 @@ import {
} from "@reactive-resume/ui/components/sheet";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor } from "@reactive-resume/utils/file";
import { orpc } from "@/libs/orpc/client";
import { mapCsvToApplications, parseCsv } from "../csv";
import { applicationsListQueryKey } from "../queries";
import { autoMapHeaders, CSV_FIELDS, mapCsvToApplications, parseCsv, rowsToCsv } from "../csv";
import { useInvalidateApplications } from "../use-application-actions";
const MAX_IMPORT = 500;
const SAMPLE =
"Company,Role,Stage,Stage Date,Location,Salary,Source,Tags,Contact Name,Contact Email,Contact Phone\nStripe,Frontend Engineer,applied,2026-07-01,Remote,$180k,LinkedIn,remote;react,Jane Doe,jane@example.com,+1 555 0100";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
const fieldLabel = (field: CsvField) =>
({
company: t`Company`,
role: t`Role`,
status: t`Stage`,
stageEnteredAt: t`Stage date`,
location: t`Location`,
salary: t`Salary`,
source: t`Source`,
sourceUrl: t`Link`,
notes: t`Notes`,
tags: t`Tags`,
contactName: t`Contact name`,
contactRole: t`Contact role`,
contactType: t`Contact label`,
contactEmail: t`Contact email`,
contactPhone: t`Contact phone`,
archived: t`Archived (closes it)`,
contacts: t`Contacts`,
})[field];
export function ImportApplicationsSheet({ open, onOpenChange }: Props) {
const queryClient = useQueryClient();
type ImportSheetProps = { open: boolean; onOpenChange: (open: boolean) => void };
/**
* Import from CSV: paste or upload, confirm how the columns were matched (they're matched automatically), then
* import. Rows with no company or role are skipped and can be downloaded to fix.
*/
export function ImportApplicationsSheet({ open, onOpenChange }: ImportSheetProps) {
const invalidate = useInvalidateApplications();
const [text, setText] = useState("");
const [overrides, setOverrides] = useState<Record<number, CsvField | null>>({});
const fileRef = useRef<HTMLInputElement>(null);
const parsed = useMemo(() => (text.trim() ? mapCsvToApplications(parseCsv(text)) : null), [text]);
const table = useMemo(() => (text.trim() ? parseCsv(text) : null), [text]);
const headers = table?.[0] ?? [];
const mapping = useMemo(
() => autoMapHeaders(headers).map((field, index) => (index in overrides ? (overrides[index] ?? null) : field)),
[headers, overrides],
);
const result = useMemo(() => (table ? mapCsvToApplications(table, mapping) : null), [table, mapping]);
// 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;
// The import endpoint takes 500 at a time; the rest is reported rather than refused.
const importable = result ? result.rows.slice(0, MAX_IMPORT) : [];
const overflow = (result?.rows.length ?? 0) - importable.length;
const resetFile = () => {
const reset = () => {
setText("");
setOverrides({});
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.add({ type: "success", description: t`Imported ${result.imported} application(s).` });
setText("");
resetFile();
onOpenChange(false);
},
onError: () => toast.add({ type: "error", description: t`Import failed. Check the CSV and try again.` }),
}),
);
const importMutation = useMutation({
...orpc.applications.import.mutationOptions(),
onSuccess: (response) => {
invalidate();
const skipped = result?.skipped ?? 0;
toast.add({
description:
skipped > 0
? t`Imported ${response.imported} applications. ${skipped} rows had no company or role and were skipped.`
: t`Imported ${response.imported} applications.`,
});
reset();
onOpenChange(false);
},
onError: () => toast.add({ type: "error", description: t`Import failed. Check the CSV and try again.` }),
});
const onFile = (file: File | undefined) => {
if (!file) return;
file.text().then(setText);
const downloadSkipped = () => {
if (!result || result.skippedRows.length === 0) return;
const blob = new Blob([rowsToCsv(headers, result.skippedRows)], { type: "text/csv" });
downloadWithAnchor(blob, "skipped-applications.csv");
};
return (
@@ -74,16 +109,16 @@ export function ImportApplicationsSheet({ open, onOpenChange }: Props) {
</SheetTitle>
<SheetDescription>
<Trans>
Paste rows or upload a .csv. We map columns like Company, Role, Stage, Stage Date, Salary, Source, Tags,
Contact Name, Contact Email and Contact Phone.
Paste rows or upload a .csv. Columns are matched automatically, and you check the match before anything is
saved.
</Trans>
</SheetDescription>
</SheetHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 pb-4">
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => fileRef.current?.click()}>
<UploadSimpleIcon />
<Icon name="upload_file" size={16} />
<Trans>Upload .csv</Trans>
</Button>
<Button size="sm" variant="ghost" onClick={() => setText(SAMPLE)}>
@@ -94,70 +129,110 @@ export function ImportApplicationsSheet({ open, onOpenChange }: Props) {
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(event) => onFile(event.target.files?.[0])}
onChange={(event) =>
void event.target.files?.[0]?.text().then((value) => {
setOverrides({});
setText(value);
})
}
/>
</div>
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">
<Label htmlFor="import-csv-text">
<Trans>CSV data</Trans>
</Label>
<Textarea
id="import-csv-text"
value={text}
rows={8}
rows={6}
placeholder="Company,Role,Stage,…"
className="font-mono text-xs"
onChange={(event) => setText(event.target.value)}
onChange={(event) => {
setOverrides({});
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-accent-text" />
<Trans>{importable.length} ready to import</Trans>
{parsed.skipped > 0 && (
<span className="text-muted-foreground text-xs">
· <Trans>{parsed.skipped} skipped (invalid or missing data)</Trans>
</span>
{result && headers.length > 0 && (
<section aria-labelledby="import-columns" className="grid gap-2">
<h3 id="import-columns" className="font-semibold text-sm">
<Trans>Columns</Trans>
</h3>
<ul className="grid gap-1.5">
{headers.map((header, index) => (
<li
key={`${header}-${index}`}
className="grid grid-cols-[minmax(0,1fr)_16px_minmax(0,1fr)] items-center gap-2 text-sm"
>
<span className="truncate font-mono text-xs">{header || t`(no header)`}</span>
<Icon name="arrow_forward" size={16} className="text-ink-3" />
<NativeSelect
aria-label={t`Column ${header} goes to`}
value={mapping[index] ?? ""}
onChange={(event) =>
setOverrides((current) => ({
...current,
[index]: (event.target.value || null) as CsvField | null,
}))
}
>
<option value="">{t`Leave out`}</option>
{CSV_FIELDS.map((field) => (
<option key={field} value={field}>
{fieldLabel(field)}
</option>
))}
</NativeSelect>
</li>
))}
</ul>
<div className="grid gap-1 rounded-lg border border-line p-3 text-sm">
<p className="flex items-center gap-1.5 font-medium">
<Icon name="check_circle" size={18} className="text-accent-text" />
<Plural
value={importable.length}
one="# application ready to import"
other="# applications ready to import"
/>
</p>
{result.skipped > 0 && (
<p className="text-ink-2 text-xs">
<Plural
value={result.skipped}
one="# row has no company or role and will be skipped."
other="# rows have no company or role and will be skipped."
/>{" "}
<button
type="button"
className="font-medium underline underline-offset-2"
onClick={downloadSkipped}
>
<Trans>Download them</Trans>
</button>
</p>
)}
{result.contactsSkipped > 0 && (
<p className="text-ink-3 text-xs">
<Plural
value={result.contactsSkipped}
one="# contact has an invalid email or no name; its application still imports."
other="# contacts have an invalid email or no name; their applications still import."
/>
</p>
)}
{overflow > 0 && (
<p className="text-warn-text text-xs">
<Trans>
Only the first {MAX_IMPORT} rows import at once, leaving out {overflow}. Split the file to import
the rest.
</Trans>
</p>
)}
</div>
{parsed.contactsSkipped > 0 && (
<p className="mt-1.5 text-muted-foreground text-xs">
<Trans>
{parsed.contactsSkipped} contact(s) skipped (invalid email or missing Contact Name). Those
applications still import.
</Trans>
</p>
)}
{overflow > 0 && (
<p className="mt-1.5 text-warn-text text-xs">
<Trans>
Only the first {MAX_IMPORT} rows import at once, leaving out {overflow}. 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="neutral" 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>
</section>
)}
</div>
@@ -167,9 +242,9 @@ export function ImportApplicationsSheet({ open, onOpenChange }: Props) {
</Button>
<Button
disabled={importable.length === 0 || importMutation.isPending}
onClick={() => importable.length > 0 && importMutation.mutate({ items: importable })}
onClick={() => importMutation.mutate({ items: importable })}
>
<Trans>Import {importable.length}</Trans>
<Plural value={importable.length} one="Import # application" other="Import # applications" />
</Button>
</SheetFooter>
</SheetContent>
@@ -1,14 +1,15 @@
import type { ApplicationTimelineEntry } from "@reactive-resume/schema/applications/data";
import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useRef } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { toast } from "@reactive-resume/ui/components/toast";
import { orpc } from "@/libs/orpc/client";
import { computeInsights, computeTimeline } from "../insights";
import { computeInsights, computeOutcomes, computeTimeline } from "../insights";
import { getStageColor, getStageLabel } from "../stages";
const byNewest = (a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) =>
new Date(b.at).getTime() - new Date(a.at).getTime();
@@ -19,98 +20,167 @@ const appliedDate = (app: Application) =>
export function ApplicationInsights({ applications }: { applications: Application[] }) {
const { data } = useQuery(orpc.applications.stats.queryOptions({}));
const { data: documents } = useQuery(orpc.documents.list.queryOptions({ input: { trashed: false } }));
// Weekly application velocity — derived from the already-loaded list, matching the stats
// population (archived excluded), so no extra endpoint is needed.
// A resume is tailored for an application when it was made for it (Copy for a job).
const madeFor = useMemo(
() =>
new Map(
(documents ?? []).flatMap((document) => (document.application ? [[document.id, document.application.id]] : [])),
),
[documents],
);
const outcomes = useMemo(
() =>
computeOutcomes(applications, (application) =>
Boolean(application.resumeId && madeFor.get(application.resumeId) === (application as Application).id),
),
[applications, madeFor],
);
// Weekly application velocity — derived from the already-loaded list, closed ones excluded.
const timeline = useMemo(
() => computeTimeline(applications.filter((app) => !app.archived).map((app) => new Date(appliedDate(app)))),
() =>
computeTimeline(applications.filter((app) => app.status !== "closed").map((app) => new Date(appliedDate(app)))),
[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" />;
if (!data) return <div className="h-40 animate-pulse rounded-xl bg-sunken" />;
const insights = computeInsights(data.byStage);
const maxSource = Math.max(1, ...data.bySource.map((s) => s.count));
const widest = Math.max(1, ...outcomes.funnel.map((row) => row.reached));
if (insights.total === 0) {
if (outcomes.sent === 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 className="py-16 text-center text-ink-2 text-sm">
<Trans>Once you've sent a few applications, this shows how far they get and how quickly people reply.</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>
<section aria-labelledby="insights-funnel" className="grid gap-3 rounded-xl border border-line p-5">
<h2 id="insights-funnel" className="font-semibold text-sm">
<Trans>How far applications get</Trans>
</h2>
<ol className="grid gap-2">
{outcomes.funnel.map((row) => (
<li key={row.status} className="grid grid-cols-[92px_minmax(0,1fr)_32px] items-center gap-3 text-sm">
<span className="flex items-center gap-1.5 text-ink-2">
<span
aria-hidden="true"
className="size-2 rounded-full"
style={{ background: getStageColor(row.status) }}
/>
{getStageLabel(row.status)}
</span>
<span className="h-5 overflow-hidden rounded bg-sunken">
<span
className="block h-full rounded"
style={{ width: `${(row.reached / widest) * 100}%`, background: getStageColor(row.status) }}
/>
</span>
<span className="text-end font-display text-base tabular-nums">{row.reached}</span>
</li>
))}
</ol>
<p className="text-ink-3 text-xs">
<Trans>Counts every application that reached each stage, including ones now closed.</Trans>
</p>
</section>
{/* 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 className="grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-line p-5">
<div className="font-display font-medium text-[34px] leading-10">
{Math.round((outcomes.heardBack / outcomes.sent) * 100)}%
</div>
))}
<div className="text-ink-2 text-sm">
<Trans>heard back</Trans>
</div>
</div>
<div className="rounded-xl border border-line p-5">
<div className="font-display font-medium text-[34px] leading-10">
{outcomes.medianDaysToReply === null ? (
"—"
) : (
<Plural value={outcomes.medianDaysToReply} one="# day" other="# days" />
)}
</div>
<div className="text-ink-2 text-sm">
<Trans>median to first reply</Trans>
</div>
</div>
</div>
{outcomes.tailored.sent + outcomes.base.sent > 0 && (
<section aria-labelledby="insights-tailored" className="grid gap-1 rounded-xl border border-line p-5">
<h2 id="insights-tailored" className="font-semibold text-sm">
<Trans>Tailored vs. base resume</Trans>
</h2>
<p className="text-ink-2 text-sm leading-6">
<Trans>
{outcomes.tailored.replied} of {outcomes.tailored.sent} tailored applications got a reply;{" "}
{outcomes.base.replied} of {outcomes.base.sent} sent with another resume.
</Trans>{" "}
{outcomes.tailored.sent + outcomes.base.sent < 10 && <Trans>Small numbers, but worth noticing.</Trans>}
</p>
</section>
)}
<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">
<div className="rounded-xl border border-line p-5">
<h3 className="font-semibold text-sm">
<Trans>Applications over time</Trans>
</h3>
<p className="mt-0.5 text-muted-foreground text-xs">
<p className="mt-0.5 text-ink-3 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>
<span className="mb-1 text-center text-[10px] text-ink-3 tabular-nums">{bucket.count || ""}</span>
<div
className="w-full rounded-t bg-primary/60"
className="w-full rounded-t bg-accent"
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>
<span className="text-[10px] text-ink-3 tabular-nums">{bucket.label}</span>
</div>
))}
</div>
</div>
{/* sources */}
<div className="rounded-xl border border-border p-5">
<div className="rounded-xl border border-line 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">
<p className="mt-0.5 text-ink-3 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">
<p className="text-ink-3 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-2.5 flex-1 overflow-hidden rounded-full bg-sunken">
<div
className="h-full rounded-full bg-foreground/70"
className="h-full rounded-full bg-ink-2"
style={{ width: `${Math.max((row.count / maxSource) * 100, 3)}%` }}
/>
</div>
<span className="w-6 text-right text-muted-foreground">{row.count}</span>
<span className="w-6 text-right text-ink-3">{row.count}</span>
</div>
))
)}
@@ -229,7 +299,7 @@ function PipelineFlow({ insights }: { insights: ReturnType<typeof computeInsight
};
return (
<div className="rounded-xl border border-border p-5">
<div className="rounded-xl border border-line p-5">
<div className="flex items-start justify-between gap-4">
<h3 className="font-semibold text-sm">
<Trans>Where your applications went</Trans>
@@ -269,11 +339,11 @@ function PipelineFlow({ insights }: { insights: ReturnType<typeof computeInsight
<text x={padX} y={60} fontSize={12} fill="#71717a">
{t`${insights.total} applications tracked`}
</text>
{insights.rejected > 0 && (
{insights.closed > 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`}
{t`${insights.closed} closed`}
</text>
</g>
)}
@@ -152,7 +152,7 @@ export function InterviewDialog({
? DURATION_OPTIONS
: [...DURATION_OPTIONS, draft.durationMinutes].sort((a, b) => a - b);
const pickable = applications.filter((item) => !item.archived);
const pickable = applications.filter((item) => item.status !== "closed");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -0,0 +1,409 @@
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Plural, Trans } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
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 { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { toast } from "@reactive-resume/ui/components/toast";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { useConfirm } from "@/hooks/use-confirm";
import { formatRelativeTime } from "@/libs/locale";
import { orpc } from "@/libs/orpc/client";
import { describeNextStep, getNextStep } from "../next-step";
import { CLOSED_REASONS, getClosedReasonLabel, getStageColor, getStageLabel, LIST_ORDER, PIPELINE } from "../stages";
import { useInvalidateApplications } from "../use-application-actions";
type SortKey = "role" | "next" | "updated";
type Sort = { key: SortKey; direction: "asc" | "desc" };
const nextStepTime = (application: Application) => {
const step = getNextStep(application);
return step.kind === "interview" || step.kind === "follow-up" ? step.at.getTime() : Number.POSITIVE_INFINITY;
};
const compare: Record<SortKey, (a: Application, b: Application) => number> = {
role: (a, b) => a.role.localeCompare(b.role) || a.company.localeCompare(b.company),
next: (a, b) => nextStepTime(a) - nextStepTime(b),
updated: (a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
};
type ListViewProps = {
applications: Application[];
showClosed: boolean;
selectedId: string | null;
onOpen: (application: Application) => void;
};
/**
* The default view: applications grouped by stage in the order that needs you first (Interview, Offer, Screening,
* Applied, Saved, then Closed when shown). Groups collapse, headers sort within groups, and row checkboxes select
* for bulk moves, tags, closing and deleting. Phones get two-line rows.
*/
export function ApplicationList({ applications, showClosed, selectedId, onOpen }: ListViewProps) {
const phone = useBreakpoint() === "mobile";
const [collapsed, setCollapsed] = useState<ReadonlySet<ApplicationStatus>>(new Set());
const [sort, setSort] = useState<Sort>({ key: "updated", direction: "desc" });
const [checked, setChecked] = useState<ReadonlySet<string>>(new Set());
// Selections only cover what's on screen.
useEffect(() => {
setChecked((current) => {
const visible = new Set(applications.map((application) => application.id));
const next = new Set([...current].filter((id) => visible.has(id)));
return next.size === current.size ? current : next;
});
}, [applications]);
const groups = useMemo(() => {
const sign = sort.direction === "asc" ? 1 : -1;
return LIST_ORDER.filter((status) => status !== "closed" || showClosed)
.map((status) => ({
status,
rows: applications
.filter((application) => application.status === status)
.sort((a, b) => sign * compare[sort.key](a, b)),
}))
.filter((group) => group.rows.length > 0);
}, [applications, showClosed, sort]);
const toggleGroup = (status: ApplicationStatus) =>
setCollapsed((current) => {
const next = new Set(current);
if (next.has(status)) next.delete(status);
else next.add(status);
return next;
});
const toggleRow = (id: string) =>
setChecked((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const sortBy = (key: SortKey) =>
setSort((current) =>
current.key === key
? { key, direction: current.direction === "asc" ? "desc" : "asc" }
: { key, direction: key === "updated" ? "desc" : "asc" },
);
const header = (key: SortKey, label: string, className?: string) => (
<th
scope="col"
aria-sort={sort.key === key ? (sort.direction === "asc" ? "ascending" : "descending") : undefined}
className={cn("px-2 text-start font-medium", className)}
>
<button
type="button"
onClick={() => sortBy(key)}
className="inline-flex h-9 items-center gap-1 rounded-md hover:text-ink"
>
{label}
{sort.key === key && <Icon name={sort.direction === "asc" ? "arrow_upward" : "arrow_downward"} size={14} />}
</button>
</th>
);
return (
<div className="grid min-w-0 content-start gap-3">
{checked.size > 0 && <BulkBar ids={[...checked]} onDone={() => setChecked(new Set())} />}
<table className="w-full border-collapse text-sm">
{!phone && (
<thead>
<tr className="border-line border-b text-ink-3 text-xs">
<th scope="col" className="w-10 ps-3">
<span className="sr-only">
<Trans>Select</Trans>
</span>
</th>
{header("role", t`Role`)}
{header("next", t`Next step`)}
<th scope="col" className="px-2 text-start font-medium max-lg:hidden">
<Trans>Stage</Trans>
</th>
<th scope="col" className="px-2 text-start font-medium">
<Trans>Sent</Trans>
</th>
{header("updated", t`Updated`, "max-md:hidden")}
</tr>
</thead>
)}
{groups.map(({ status, rows }) => {
const open = !collapsed.has(status);
const groupId = `applications-group-${status}`;
return (
<tbody key={status} aria-labelledby={groupId}>
<tr>
<th scope="colgroup" colSpan={6} className="pt-3 text-start">
<button
type="button"
id={groupId}
aria-expanded={open}
onClick={() => toggleGroup(status)}
className="flex h-9 items-center gap-2 rounded-md px-1.5 font-semibold text-sm hover:bg-hover"
>
<Icon
name="chevron_right"
size={18}
className={cn("text-ink-3 transition-transform duration-standard", open && "rotate-90")}
/>
<span
aria-hidden="true"
className="size-2 rounded-full"
style={{ background: getStageColor(status) }}
/>
{getStageLabel(status)}
<span className="font-mono font-normal text-ink-3 text-xs">{rows.length}</span>
</button>
</th>
</tr>
{open &&
rows.map((application) => (
<ApplicationRow
key={application.id}
application={application}
phone={phone}
selected={application.id === selectedId}
checked={checked.has(application.id)}
onCheck={() => toggleRow(application.id)}
onOpen={() => onOpen(application)}
/>
))}
</tbody>
);
})}
</table>
</div>
);
}
type ApplicationRowProps = {
application: Application;
phone: boolean;
selected: boolean;
checked: boolean;
onCheck: () => void;
onOpen: () => void;
};
function ApplicationRow({ application, phone, selected, checked, onCheck, onOpen }: ApplicationRowProps) {
const { i18n } = useLingui();
const next = describeNextStep(getNextStep(application), application, i18n.locale);
const formatter = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
const sent = application.status !== "saved";
const tone = next.tone === "warn" ? "text-warn-text" : next.tone === "muted" ? "text-ink-3" : "text-ink-2";
const initial = (
<span
aria-hidden="true"
className="grid size-8 shrink-0 place-items-center rounded-[7px] bg-sunken font-semibold text-[13px] text-ink-2"
>
{application.company.slice(0, 1).toUpperCase()}
</span>
);
const openButton = (
<button type="button" onClick={onOpen} className="grid min-w-0 text-start after:absolute after:inset-0">
<span className="truncate font-medium">{application.role}</span>
<span className="truncate text-ink-3 text-xs">
{phone
? `${application.company} · ${next.title}`
: [application.company, application.location].filter(Boolean).join(" · ")}
</span>
</button>
);
if (phone) {
return (
<tr className={cn("relative border-line border-b", selected && "bg-accent-soft/50")}>
<td className="py-2.5 ps-1">
<div className="flex items-center gap-3">
{initial}
{openButton}
<Icon name="chevron_right" size={20} className="ms-auto shrink-0 text-ink-3" />
</div>
</td>
</tr>
);
}
return (
<tr
className={cn(
"relative border-line border-b transition-colors duration-quick hover:bg-hover",
selected && "bg-accent-soft/50",
)}
>
<td className="relative z-10 w-10 ps-3">
<Checkbox
checked={checked}
onCheckedChange={onCheck}
aria-label={t`Select ${application.role} at ${application.company}`}
/>
</td>
<td className="py-2 pe-2">
<div className="flex items-center gap-3">
{initial}
{openButton}
</div>
</td>
<td className="px-2">
<span className={cn("flex items-center gap-1.5", tone)}>
<Icon name={next.icon} size={16} className="shrink-0" />
<span className="truncate">{next.title}</span>
</span>
</td>
<td className="px-2 max-lg:hidden">
<span className="flex items-center gap-1.5 text-ink-2">
<span
aria-hidden="true"
className="size-2 rounded-full"
style={{ background: getStageColor(application.status) }}
/>
{getStageLabel(application.status)}
</span>
</td>
<td className="px-2">
{application.resumeId || application.coverLetterId ? (
<span className="flex items-center gap-1 text-ink-2">
{application.resumeId && <Icon name="description" size={18} aria-label={t`Resume`} />}
{application.coverLetterId && <Icon name="mail" size={18} aria-label={t`Cover letter`} />}
</span>
) : (
<span className={cn("text-xs", sent ? "text-warn-text" : "text-ink-3")}>
<Trans>None</Trans>
</span>
)}
</td>
<td className="whitespace-nowrap px-2 text-ink-3 text-xs max-md:hidden">
{formatRelativeTime(application.updatedAt, formatter)}
</td>
</tr>
);
}
type BulkBarProps = { ids: string[]; onDone: () => void };
/** For the checked rows: move them, tag them, close them with a reason, or delete them (after asking). */
function BulkBar({ ids, onDone }: BulkBarProps) {
const invalidate = useInvalidateApplications();
const confirm = useConfirm();
const [tag, setTag] = useState("");
const bulkUpdate = useMutation({
...orpc.applications.bulkUpdate.mutationOptions(),
onSuccess: (result) => {
invalidate();
onDone();
toast.add({ description: t`Updated ${result.updated}` });
},
onError: () => toast.add({ type: "error", description: t`Couldn't update the applications. Try again.` }),
});
const bulkDelete = useMutation({
...orpc.applications.bulkDelete.mutationOptions(),
onSuccess: (result) => {
invalidate();
onDone();
toast.add({ description: t`Deleted ${result.deleted}` });
},
onError: () => toast.add({ type: "error", description: t`Couldn't delete the applications. Try again.` }),
});
return (
<div className="flex flex-wrap items-center gap-1.5 rounded-xl bg-ink px-3 py-2 text-bg">
<span className="me-1 font-semibold text-sm">
<Plural value={ids.length} one="# selected" other="# selected" />
</span>
<DropdownMenu>
<DropdownMenuTrigger render={<Button size="sm" variant="secondary" />}>
<Icon name="arrow_forward" size={16} />
<Trans>Move to…</Trans>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{PIPELINE.map((status) => (
<DropdownMenuItem key={status} onClick={() => bulkUpdate.mutate({ ids, status })}>
<span aria-hidden="true" className="size-2 rounded-full" style={{ background: getStageColor(status) }} />
{getStageLabel(status)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Popover>
<PopoverTrigger render={<Button size="sm" variant="secondary" />}>
<Icon name="sell" size={16} />
<Trans>Add tag</Trans>
</PopoverTrigger>
<PopoverContent align="start" className="w-60 p-2">
<form
className="flex gap-1.5"
onSubmit={(event) => {
event.preventDefault();
if (!tag.trim()) return;
bulkUpdate.mutate({ ids, addTags: [tag.trim()] });
setTag("");
}}
>
<Input aria-label={t`Tag`} value={tag} onChange={(event) => setTag(event.target.value)} autoFocus />
<Button size="sm" type="submit" disabled={!tag.trim()}>
<Trans>Add</Trans>
</Button>
</form>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger render={<Button size="sm" variant="secondary" />}>
<Trans>Close…</Trans>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{CLOSED_REASONS.map((reason) => (
<DropdownMenuItem
key={reason}
onClick={() => bulkUpdate.mutate({ ids, status: "closed", closedReason: reason })}
>
{getClosedReasonLabel(reason)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
size="sm"
variant="secondary"
className="text-danger-text"
onClick={async () => {
const confirmed = await confirm(t`Delete ${ids.length} applications?`, {
description: t`They and their timelines are deleted permanently. This can't be undone.`,
confirmText: t`Delete`,
});
if (confirmed) bulkDelete.mutate({ ids });
}}
>
<Trans>Delete…</Trans>
</Button>
<Button size="sm" variant="ghost" className="ms-auto text-bg hover:bg-bg/15" onClick={onDone}>
<Trans>Clear</Trans>
</Button>
</div>
);
}
@@ -1,397 +0,0 @@
import type { ApplicationStatus, ApplicationTimelineEntry } from "@reactive-resume/schema/applications/data";
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 { 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 { toast } from "@reactive-resume/ui/components/toast";
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 { ApplicationActionsMenu } from "./application-actions-menu";
const PAGE_SIZE = 25;
const stageOf = (status: string) => STAGES.find((s) => s.value === status);
const byNewest = (a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) =>
new Date(b.at).getTime() - new Date(a.at).getTime();
const latestStageDate = (activity: ApplicationTimelineEntry[], status: ApplicationStatus) =>
[...activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === status)?.at;
const formatDate = (value: Date | string) =>
new Date(value).toLocaleDateString(undefined, { month: "numeric", day: "numeric", timeZone: "UTC", year: "numeric" });
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.add({ type: "error", description: t`Bulk update failed. Please try again.` }),
}),
);
const bulkDelete = useMutation(
orpc.applications.bulkDelete.mutationOptions({
onSuccess: (result) => {
invalidate();
clearSelection();
toast.add({ type: "success", description: t`Deleted ${result.deleted} application(s).` });
},
onError: () => toast.add({ type: "error", description: 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 bg-sunken font-semibold text-[11px] text-ink-2",
)}
>
{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="neutral" 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">
{formatDate(latestStageDate(app.activity, "applied") ?? app.appliedAt)}
</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 bg-sunken font-semibold text-[11px] text-ink-2",
)}
>
{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="secondary" disabled={safePage === 0} onClick={() => setPage(safePage - 1)}>
‹
</Button>
<span className="text-xs">
{safePage + 1} / {pageCount}
</span>
<Button
size="sm"
variant="secondary"
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>
);
}
@@ -8,6 +8,11 @@ const application: Application = {
role: "Engineer",
status: "interview",
archived: false,
closedReason: null,
coverLetterId: null,
sentResumeVersionId: null,
sentCheckScore: null,
requirements: [],
location: "Berlin",
salary: "€70,000",
source: "Referral",
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { mapCsvToApplications, parseCsv } from "./csv";
import { autoMapHeaders, mapCsvToApplications, parseCsv, rowsToCsv } from "./csv";
describe("parseCsv", () => {
it("parses quoted fields with commas and newlines", () => {
@@ -77,3 +77,24 @@ describe("mapCsvToApplications", () => {
expect(contactsSkipped).toBe(1);
});
});
describe("column matching", () => {
const table = parseCsv(
"Employer,Job Title,Status,Archived,Mystery\nAcme,Designer,rejected,false,x\nKiln,Engineer,applied,true,y\n,No company,saved,false,z",
);
it("matches known headers automatically and leaves the rest out", () => {
expect(autoMapHeaders(table[0] ?? [])).toEqual(["company", "role", "status", "archived", null]);
});
it("uses the confirmed match, reads retired stages as closed, and keeps skipped rows to download", () => {
const result = mapCsvToApplications(table, ["company", "role", "status", "archived", "notes"]);
expect(result.rows.map((row) => [row.company, row.status, row.notes])).toEqual([
["Acme", "closed", "x"],
["Kiln", "closed", "y"],
]);
expect(result.skippedRows).toEqual([["", "No company", "saved", "false", "z"]]);
expect(rowsToCsv(["Company", "Role"], [["", 'Say "hi"']])).toBe('"Company","Role"\r\n"","Say ""hi"""\r\n');
});
});
+53 -10
View File
@@ -1,7 +1,7 @@
import type { ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
import type { Application } from "./types";
import {
applicationStatusSchema,
applicationStatusInputSchema,
contactSchema,
INTERVIEW_KINDS,
STAGES,
@@ -78,8 +78,13 @@ type CsvApplication = ParsedApplication & {
contactType?: string;
contactEmail?: string;
contactPhone?: string;
/** Exports from before the closed stage mark archived applications here. */
archived?: string;
};
/** A column's destination, or null to leave the column out. */
export type CsvField = keyof CsvApplication;
function dateOnly(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
const [year, month, day] = value.split("-").map(Number);
@@ -118,8 +123,39 @@ const HEADER_ALIASES: Record<string, keyof CsvApplication> = {
"contact type": "contactType",
"contact email": "contactEmail",
"contact phone": "contactPhone",
archived: "archived",
};
/** The fields a column can be matched to, in the order the match list offers them. */
export const CSV_FIELDS: readonly CsvField[] = [
"company",
"role",
"status",
"stageEnteredAt",
"location",
"salary",
"source",
"sourceUrl",
"notes",
"tags",
"contactName",
"contactRole",
"contactType",
"contactEmail",
"contactPhone",
"archived",
];
/** Each header's field by name (case and spacing ignored), or null when nothing matches. */
export const autoMapHeaders = (headers: readonly string[]): (CsvField | null)[] =>
headers.map((header) => HEADER_ALIASES[header.trim().toLowerCase()] ?? null);
const csvLine = (cells: readonly string[]) => cells.map((cell) => `"${cell.replaceAll('"', '""')}"`).join(",");
/** Rows back to CSV text (for downloading the rows an import skipped), header first. */
export const rowsToCsv = (headers: readonly string[], rows: readonly string[][]) =>
`${[headers, ...rows].map(csvLine).join("\r\n")}\r\n`;
// Values a spreadsheet would evaluate as a formula: leading =, +, -, @ (and full-width variants),
// possibly hidden behind whitespace/control characters, or a leading tab/newline.
function isFormulaLike(value: string) {
@@ -152,6 +188,8 @@ function parseTags(value: string) {
export type CsvMapResult = {
rows: ParsedApplication[];
skipped: number;
/** The source rows left out (no company or role), to download and fix. */
skippedRows: string[][];
contactsSkipped: number;
headers: string[];
recognized: string[];
@@ -161,19 +199,20 @@ export type CsvMapResult = {
// are skipped (and counted). Status is coerced to a valid stage or dropped. A contact that fails
// validation (bad email, or contact columns with no name) is dropped on its own — the application
// still imports, since losing the whole row would silently discard company/role/salary/tags too.
export function mapCsvToApplications(table: string[][]): CsvMapResult {
export function mapCsvToApplications(table: string[][], mapping?: readonly (CsvField | null)[]): CsvMapResult {
const [headerRow, ...dataRows] = table;
if (!headerRow) return { rows: [], skipped: 0, contactsSkipped: 0, headers: [], recognized: [] };
if (!headerRow) return { rows: [], skipped: 0, skippedRows: [], contactsSkipped: 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 CsvApplication => !!f))];
// The confirmed match, else the automatic one.
const fieldFor = mapping ?? autoMapHeaders(headers);
const recognized = [...new Set(fieldFor.filter((f): f is CsvField => !!f))];
const isReactiveResumeExport = ["Stage History", "Timeline", "Archived", "Created At", "Updated At"].every((header) =>
headers.includes(header),
);
const rows: ParsedApplication[] = [];
let skipped = 0;
const skippedRows: string[][] = [];
let contactsSkipped = 0;
for (const raw of dataRows) {
@@ -185,7 +224,7 @@ export function mapCsvToApplications(table: string[][]): CsvMapResult {
if (!value) return;
if (field === "tags") record.tags = parseTags(value);
else if (field === "status") {
const parsed = applicationStatusSchema.safeParse(value.toLowerCase());
const parsed = applicationStatusInputSchema.safeParse(value.toLowerCase());
if (parsed.success) record.status = parsed.data;
} else if (field === "stageEnteredAt") {
record.stageEnteredAt = dateOnly(value);
@@ -193,11 +232,13 @@ export function mapCsvToApplications(table: string[][]): CsvMapResult {
});
if (!record.company || !record.role) {
skipped++;
skippedRows.push(raw);
continue;
}
const { contactName, contactRole, contactType, contactEmail, contactPhone, ...application } = record;
const { contactName, contactRole, contactType, contactEmail, contactPhone, archived, ...application } = record;
// Archived applications from older exports are closed ones.
if (archived?.toLowerCase() === "true") application.status = "closed";
if (contactName || contactRole || contactType || contactEmail || contactPhone) {
const contact = contactSchema.safeParse({
name: contactName,
@@ -212,7 +253,7 @@ export function mapCsvToApplications(table: string[][]): CsvMapResult {
rows.push(application as ParsedApplication);
}
return { rows, skipped, contactsSkipped, headers, recognized };
return { rows, skipped: skippedRows.length, skippedRows, contactsSkipped, headers, recognized };
}
export type ApplicationExportOptions = {
@@ -253,6 +294,7 @@ export function exportApplicationsCsv(applications: readonly Application[]): str
"Tags",
"Contacts",
"Notes",
"Closed Reason",
"Stage History",
"Timeline",
"Archived",
@@ -289,6 +331,7 @@ export function exportApplicationsCsv(applications: readonly Application[]): str
})
.join("\n"),
application.notes ?? "",
application.closedReason ?? "",
stages.map((entry) => `${stageLabel(entry.stage)} (${dateOnly(entry.at)})`).join(" → "),
timeline.map((entry) => `${dateOnly(entry.at)}: ${timelineText(entry)}`).join("\n"),
String(application.archived),
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { buildIcs } from "./ics";
describe("buildIcs", () => {
it("writes one UTC event with escaped text and CRLF lines", () => {
const ics = buildIcs(
{
uid: "i1",
title: "Panel interview, Lumen",
start: new Date("2026-10-02T12:00:00Z"),
end: new Date("2026-10-02T13:00:00Z"),
location: "Berlin; Room 4",
description: "Bring the portfolio\\nand notes",
},
new Date("2026-09-28T08:30:00Z"),
);
expect(ics.split("\r\n")).toEqual([
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Reactive Resume//Applications//EN",
"CALSCALE:GREGORIAN",
"BEGIN:VEVENT",
"UID:i1@reactive-resume",
"DTSTAMP:20260928T083000Z",
"DTSTART:20261002T120000Z",
"DTEND:20261002T130000Z",
"SUMMARY:Panel interview\\, Lumen",
"LOCATION:Berlin\\; Room 4",
"DESCRIPTION:Bring the portfolio\\\\nand notes",
"END:VEVENT",
"END:VCALENDAR",
"",
]);
});
it("folds long lines at 75 characters", () => {
const ics = buildIcs({ uid: "x", title: "A".repeat(100), start: new Date(0), end: new Date(0) });
const summary = ics.split("\r\n").filter((line) => line.startsWith("SUMMARY") || line.startsWith(" "));
expect(summary[0]).toHaveLength(75);
expect(summary[1]?.startsWith(" ")).toBe(true);
});
});
+52
View File
@@ -0,0 +1,52 @@
type CalendarEvent = {
uid: string;
title: string;
start: Date;
end: Date;
location?: string;
description?: string;
};
// RFC 5545: commas, semicolons and backslashes are escaped, line breaks become \n.
const escapeText = (value: string) =>
value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
const utcStamp = (date: Date) =>
date
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}/, "");
// Lines longer than 75 octets fold onto continuation lines that start with a space.
function fold(line: string) {
const parts: string[] = [];
let rest = line;
while (rest.length > 75) {
parts.push(rest.slice(0, 75));
rest = ` ${rest.slice(75)}`;
}
parts.push(rest);
return parts.join("\r\n");
}
/** One event as an .ics file's text, in UTC, for "Add to calendar". */
export function buildIcs(event: CalendarEvent, now = new Date()): string {
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Reactive Resume//Applications//EN",
"CALSCALE:GREGORIAN",
"BEGIN:VEVENT",
`UID:${event.uid}@reactive-resume`,
`DTSTAMP:${utcStamp(now)}`,
`DTSTART:${utcStamp(event.start)}`,
`DTEND:${utcStamp(event.end)}`,
`SUMMARY:${escapeText(event.title)}`,
...(event.location ? [`LOCATION:${escapeText(event.location)}`] : []),
...(event.description ? [`DESCRIPTION:${escapeText(event.description)}`] : []),
"END:VEVENT",
"END:VCALENDAR",
];
return `${lines.map(fold).join("\r\n")}\r\n`;
}
@@ -1,6 +1,6 @@
import type { StageCount } from "./insights";
import { describe, expect, it } from "vitest";
import { computeInsights } from "./insights";
import { computeInsights, computeOutcomes } from "./insights";
const byStage: StageCount[] = [
{ status: "saved", count: 2 },
@@ -8,11 +8,11 @@ const byStage: StageCount[] = [
{ status: "screening", count: 2 },
{ status: "interview", count: 1 },
{ status: "offer", count: 1 },
{ status: "rejected", count: 2 },
{ status: "closed", count: 2 },
];
describe("computeInsights", () => {
it("totals every stage including rejected", () => {
it("totals every stage including closed", () => {
expect(computeInsights(byStage).total).toBe(11);
});
@@ -37,3 +37,64 @@ describe("computeInsights", () => {
expect(empty.funnel[0]?.pct).toBe(0);
});
});
describe("computeOutcomes", () => {
const at = (day: number) => new Date(Date.UTC(2026, 8, day, 12));
const stage = (stage: StageCount["status"], day: number) => ({
id: `${stage}${day}`,
type: "stage" as const,
stage,
at: at(day),
});
const applications = [
// Sent on the 1st, heard back (screening) on the 5th, then turned down.
{
status: "closed" as const,
closedReason: "not-selected" as const,
resumeId: "tailored",
appliedAt: at(1),
activity: [stage("applied", 1), stage("screening", 5), stage("closed", 9)],
},
// Sent on the 2nd, interview on the 12th.
{
status: "interview" as const,
closedReason: null,
resumeId: "base",
appliedAt: at(2),
activity: [stage("saved", 1), stage("applied", 2), stage("interview", 12)],
},
// Sent, no reply yet.
{
status: "applied" as const,
closedReason: null,
resumeId: "base",
appliedAt: at(3),
activity: [stage("applied", 3)],
},
// Never sent.
{ status: "saved" as const, closedReason: null, resumeId: null, appliedAt: at(4), activity: [stage("saved", 4)] },
];
const outcomes = computeOutcomes(applications, (application) => application.resumeId === "tailored");
it("counts every application that reached each stage, closed ones included", () => {
expect(outcomes.funnel).toEqual([
{ status: "applied", reached: 3 },
{ status: "screening", reached: 2 },
{ status: "interview", reached: 1 },
{ status: "offer", reached: 0 },
]);
});
it("measures replies and the median days to the first one", () => {
expect(outcomes.sent).toBe(3);
expect(outcomes.heardBack).toBe(2);
expect(outcomes.medianDaysToReply).toBe(7); // 4 and 10 days
});
it("compares resumes made for the job with the base resume", () => {
expect(outcomes.tailored).toEqual({ sent: 1, replied: 1 });
expect(outcomes.base).toEqual({ sent: 2, replied: 1 });
});
});
+95 -5
View File
@@ -1,16 +1,18 @@
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
import type { Application } from "./types";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { PIPELINE } from "./stages";
export type StageCount = { status: ApplicationStatus; count: number };
// The forward pipeline (rejected is a terminal outcome, handled separately).
// The forward pipeline (closed 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;
closed: number;
};
// Pure function of the raw per-stage counts, so it's trivially testable and shared by every
@@ -20,7 +22,7 @@ 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");
const closed = at("closed");
// 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));
@@ -49,10 +51,10 @@ export function computeInsights(byStage: StageCount[]): Insights {
{ 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" },
{ label: "Offers", value: String(offers), sub: closed > 0 ? `${closed} closed` : "so far" },
];
return { total, tiles, funnel, rejected };
return { total, tiles, funnel, closed };
}
export type TimelineBucket = { label: string; count: number };
@@ -81,3 +83,91 @@ export function computeTimeline(dates: Date[], weeks = 8): TimelineBucket[] {
return buckets.map(({ label, count }) => ({ label, count }));
}
type OutcomeSource = Pick<Application, "status" | "closedReason" | "activity" | "appliedAt" | "resumeId">;
const DAY_MS = 86_400_000;
const REPLY_STAGES = new Set<ApplicationStatus>(["screening", "interview", "offer"]);
const stageEntries = (application: OutcomeSource) =>
application.activity
.flatMap((entry) => (entry.type === "stage" ? [{ stage: entry.stage, at: new Date(entry.at) }] : []))
.sort((a, b) => a.at.getTime() - b.at.getTime());
/** How far along the pipeline an application ever got, from its stage history; closed ones keep their furthest stage. */
function furthestStage(application: OutcomeSource) {
const stages = [...stageEntries(application).map((entry) => entry.stage), application.status];
return Math.max(...stages.map((stage) => PIPELINE.indexOf(stage)));
}
/** When the application was sent: its first stage at Applied or beyond, else the applied date on record. */
function sentAt(application: OutcomeSource) {
const applied = PIPELINE.indexOf("applied");
return (
stageEntries(application).find((entry) => PIPELINE.indexOf(entry.stage) >= applied)?.at ??
new Date(application.appliedAt)
);
}
/** The first reply after sending: moving on to screening or beyond, or being turned down. */
function firstReply(application: OutcomeSource) {
const since = sentAt(application).getTime();
return stageEntries(application).find(
(entry) =>
entry.at.getTime() >= since &&
(REPLY_STAGES.has(entry.stage) || (entry.stage === "closed" && application.closedReason === "not-selected")),
)?.at;
}
function median(values: number[]) {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 ? (sorted[middle] ?? null) : ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2;
}
export type Outcomes = {
/** Applications that ever reached each stage from Applied on, closed ones included. */
funnel: { status: ApplicationStatus; reached: number }[];
sent: number;
heardBack: number;
medianDaysToReply: number | null;
/** Sent applications whose linked resume was made for them, against those sent with another resume. */
tailored: { sent: number; replied: number };
base: { sent: number; replied: number };
};
/**
* How applications turned out: the funnel of stages reached, how many heard back and how fast, and whether
* resumes made for the job got more replies than the base resume. `isTailored` says whether an application's
* linked resume was made for it.
*/
export function computeOutcomes(
applications: readonly OutcomeSource[],
isTailored: (application: OutcomeSource) => boolean,
): Outcomes {
const sent = applications.filter((application) => furthestStage(application) >= PIPELINE.indexOf("applied"));
const replies = sent.map((application) => ({ application, reply: firstReply(application) }));
const withReplies = replies.filter((entry) => entry.reply !== undefined);
const compare = (tailored: boolean) => {
const group = replies.filter(({ application }) => application.resumeId && isTailored(application) === tailored);
return { sent: group.length, replied: group.filter((entry) => entry.reply !== undefined).length };
};
return {
funnel: (["applied", "screening", "interview", "offer"] as const).map((status) => ({
status,
reached: sent.filter((application) => furthestStage(application) >= PIPELINE.indexOf(status)).length,
})),
sent: sent.length,
heardBack: withReplies.length,
medianDaysToReply: median(
withReplies.map(({ application, reply }) =>
Math.round(((reply?.getTime() ?? 0) - sentAt(application).getTime()) / DAY_MS),
),
),
tailored: compare(true),
base: compare(false),
};
}
@@ -0,0 +1,63 @@
import type { Application } from "./types";
import { describe, expect, it } from "vitest";
import { getNextStep, isOverdue } from "./next-step";
const NOW = new Date("2026-09-28T12:00:00Z");
const day = (offset: number) => new Date(NOW.getTime() + offset * 86_400_000);
type Source = Parameters<typeof getNextStep>[0];
const application = (patch: Partial<Source> = {}): Source => ({
status: "applied",
activity: [{ id: "s1", type: "stage", stage: "applied", at: day(-3) }],
followUpAt: null,
followUpNote: null,
appliedAt: day(-3),
...patch,
});
const interview = (at: Date, id = "i1"): Application["activity"][number] => ({
id,
type: "interview",
at,
kind: "onsite",
durationMinutes: 60,
location: "",
notes: "",
});
describe("getNextStep", () => {
it("puts the earliest interview still to come first", () => {
const step = getNextStep(
application({
status: "interview",
followUpAt: day(1),
activity: [interview(day(-2), "past"), interview(day(5), "later"), interview(day(2), "soon")],
}),
NOW,
);
expect(step).toMatchObject({ kind: "interview", interview: { id: "soon" } });
});
it("falls back to the follow-up date, overdue once it has passed", () => {
expect(getNextStep(application({ followUpAt: day(2), followUpNote: "Email Maya" }), NOW)).toMatchObject({
kind: "follow-up",
note: "Email Maya",
overdue: false,
});
const late = getNextStep(application({ followUpAt: day(-1) }), NOW);
expect(late).toMatchObject({ kind: "follow-up", overdue: true });
expect(isOverdue(late)).toBe(true);
});
it("suggests a follow-up after ten days without a reply, and counts the wait before that", () => {
expect(getNextStep(application(), NOW)).toEqual({ kind: "waiting", days: 3 });
const quiet = application({ activity: [{ id: "s1", type: "stage", stage: "applied", at: day(-12) }] });
expect(getNextStep(quiet, NOW)).toEqual({ kind: "no-reply", days: 12 });
});
it("has nothing next for saved and closed applications beyond their state", () => {
expect(getNextStep(application({ status: "saved" }), NOW)).toEqual({ kind: "not-applied" });
expect(getNextStep(application({ status: "closed", followUpAt: day(1) }), NOW)).toEqual({ kind: "closed" });
});
});
@@ -0,0 +1,130 @@
import type { InterviewTimelineEntry } from "@reactive-resume/schema/applications/data";
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { Application } from "./types";
import { plural, t } from "@lingui/core/macro";
import { interviewKindOf, isInterview } from "./interviews";
import { getClosedReasonLabel } from "./stages";
const DAY_MS = 86_400_000;
/** Days without a reply after applying before a follow-up is suggested. */
const FOLLOW_UP_AFTER_DAYS = 10;
type NextStepSource = Pick<Application, "status" | "activity" | "followUpAt" | "followUpNote" | "appliedAt">;
/**
* What an application needs next, derived rather than stored: the earliest interview still to come, else the
* follow-up date, else how long it has been waiting. Closed applications need nothing.
*/
export type NextStep =
| { kind: "interview"; interview: InterviewTimelineEntry; at: Date }
| { kind: "follow-up"; at: Date; note: string; overdue: boolean }
| { kind: "no-reply"; days: number }
| { kind: "waiting"; days: number }
| { kind: "not-applied" }
| { kind: "closed" };
/** When the application entered its current stage (its latest entry for that stage), or when it was added. */
export function stageSince(application: Pick<Application, "status" | "activity" | "appliedAt">): Date {
const entries = application.activity
.filter((entry) => entry.type === "stage" && entry.stage === application.status)
.map((entry) => new Date(entry.at).getTime());
return new Date(entries.length > 0 ? Math.max(...entries) : new Date(application.appliedAt).getTime());
}
const daysBetween = (from: Date, to: Date) => Math.max(0, Math.floor((to.getTime() - from.getTime()) / DAY_MS));
export function getNextStep(application: NextStepSource, now = new Date()): NextStep {
if (application.status === "closed") return { kind: "closed" };
const upcoming = application.activity
.filter(isInterview)
.map((interview) => ({ interview, at: new Date(interview.at) }))
.filter(({ interview, at }) => at.getTime() + interview.durationMinutes * 60_000 >= now.getTime())
.sort((a, b) => a.at.getTime() - b.at.getTime())[0];
if (upcoming) return { kind: "interview", ...upcoming };
if (application.followUpAt) {
const at = new Date(application.followUpAt);
return { kind: "follow-up", at, note: application.followUpNote ?? "", overdue: at.getTime() < now.getTime() };
}
if (application.status === "saved") return { kind: "not-applied" };
const days = daysBetween(stageSince(application), now);
if (application.status === "applied" && days >= FOLLOW_UP_AFTER_DAYS) return { kind: "no-reply", days };
return { kind: "waiting", days };
}
/** Whether a next step needs attention now: an overdue follow-up, or no reply for too long. */
export const isOverdue = (step: NextStep) => step.kind === "no-reply" || (step.kind === "follow-up" && step.overdue);
export type NextStepText = { icon: IconName; title: string; sub: string; tone: "normal" | "warn" | "muted" };
const formatWhen = (date: Date, locale: string, withTime: boolean) =>
date.toLocaleString(locale, {
weekday: "short",
day: "numeric",
month: "short",
...(withTime ? { hour: "2-digit", minute: "2-digit" } : {}),
});
/** How a next step reads in the list, on cards and in the detail sheet. */
export function describeNextStep(
step: NextStep,
application: Pick<Application, "status" | "closedReason">,
locale: string,
): NextStepText {
switch (step.kind) {
case "interview": {
const kind = interviewKindOf(step.interview.kind)?.label ?? step.interview.kind;
const where = step.interview.location ? ` · ${step.interview.location}` : "";
return {
icon: "event",
title: t`${kind} interview`,
sub: `${formatWhen(step.at, locale, true)}${where}`,
tone: "normal",
};
}
case "follow-up":
return {
icon: step.overdue ? "schedule" : "event",
title: step.note || t`Follow up`,
sub: formatWhen(step.at, locale, false),
tone: step.overdue ? "warn" : "normal",
};
case "no-reply":
return {
icon: "schedule",
title: plural(step.days, { one: "No reply in # day", other: "No reply in # days" }),
sub: t`A short follow-up is usually fine now`,
tone: "warn",
};
case "waiting":
return application.status === "applied"
? {
icon: "hourglass_empty",
title:
step.days === 0
? t`Applied today`
: plural(step.days, { one: "Applied # day ago", other: "Applied # days ago" }),
sub: "",
tone: "normal",
}
: {
icon: "hourglass_empty",
title: t`Waiting to hear back`,
sub: plural(step.days, { one: "For # day", other: "For # days" }),
tone: "normal",
};
case "not-applied":
return { icon: "bookmark", title: t`Not applied yet`, sub: "", tone: "muted" };
case "closed":
return {
icon: "check",
title: application.closedReason ? getClosedReasonLabel(application.closedReason) : t`Closed`,
sub: "",
tone: "muted",
};
}
}
@@ -0,0 +1,50 @@
import type { ApplicationClosedReason, ApplicationStatus } from "@reactive-resume/schema/applications/data";
import { t } from "@lingui/core/macro";
import { STAGES } from "@reactive-resume/schema/applications/data";
/** The stages an application moves through; closed ends it from any of them. */
export const PIPELINE: readonly ApplicationStatus[] = ["saved", "applied", "screening", "interview", "offer"];
/** The list's group order: what needs you first, closed last. */
export const LIST_ORDER: readonly ApplicationStatus[] = [
"interview",
"offer",
"screening",
"applied",
"saved",
"closed",
];
export const CLOSED_REASONS: readonly ApplicationClosedReason[] = [
"not-selected",
"withdrew",
"accepted-other",
"no-response",
];
export const getStageLabel = (status: ApplicationStatus) =>
({
saved: t`Saved`,
applied: t`Applied`,
screening: t`Screening`,
interview: t`Interview`,
offer: t`Offer`,
closed: t`Closed`,
})[status];
export const getStageColor = (status: ApplicationStatus) =>
STAGES.find((stage) => stage.value === status)?.color ?? "var(--ink-3)";
export const getClosedReasonLabel = (reason: ApplicationClosedReason) =>
({
"not-selected": t`Not selected`,
withdrew: t`I withdrew`,
"accepted-other": t`Accepted another offer`,
"no-response": t`No response`,
})[reason];
/** The stage after this one in the pipeline, or null at Offer and for closed applications. */
export function getNextStage(status: ApplicationStatus): ApplicationStatus | null {
const index = PIPELINE.indexOf(status);
return index === -1 ? null : (PIPELINE[index + 1] ?? null);
}
@@ -0,0 +1,89 @@
import type { ApplicationClosedReason, ApplicationStatus } from "@reactive-resume/schema/applications/data";
import type { Application } from "./types";
import { t } from "@lingui/core/macro";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "@reactive-resume/ui/components/toast";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "./queries";
import { getStageLabel } from "./stages";
/** Refreshes everything that shows applications: the list, the open one, stats, tags and documents' job lines. */
export function useInvalidateApplications() {
const queryClient = useQueryClient();
return (id?: string) => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() });
void queryClient.invalidateQueries({ queryKey: orpc.documents.list.key() });
if (id) void queryClient.invalidateQueries({ queryKey: orpc.applications.getById.queryKey({ input: { id } }) });
};
}
const failed = (error: unknown) =>
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Something went wrong. Try again.` }),
});
/**
* Stage changes, closing and deleting, shared by the list, board and detail sheet. A move shows at once (the list
* updates optimistically) and says so in a toast, whichever way it was made.
*/
export function useApplicationActions() {
const queryClient = useQueryClient();
const invalidate = useInvalidateApplications();
const listKey = applicationsListQueryKey();
const update = useMutation({
...orpc.applications.update.mutationOptions(),
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: listKey });
const previous = queryClient.getQueryData<Application[]>(listKey);
const status = input.status as ApplicationStatus | undefined;
queryClient.setQueryData<Application[]>(listKey, (rows) =>
rows?.map(
(row): Application =>
row.id === input.id
? {
...row,
...(status ? { status } : {}),
...(input.closedReason !== undefined ? { closedReason: input.closedReason } : {}),
}
: row,
),
);
return { previous };
},
onError: (error, _input, context) => {
if (context?.previous) queryClient.setQueryData(listKey, context.previous);
failed(error);
},
onSettled: (_data, _error, input) => invalidate(input.id),
});
const remove = useMutation({
...orpc.applications.delete.mutationOptions(),
onSuccess: (_data, input) => {
invalidate(input.id);
toast.add({ description: t`Application deleted` });
},
onError: failed,
});
return {
update,
remove,
moveTo: (application: Pick<Application, "id">, status: ApplicationStatus) =>
update.mutate(
{ id: application.id, status },
{ onSuccess: () => toast.add({ description: t`Moved to ${getStageLabel(status)}` }) },
),
close: (application: Pick<Application, "id">, closedReason: ApplicationClosedReason) =>
update.mutate(
{ id: application.id, status: "closed", closedReason },
{ onSuccess: () => toast.add({ description: t`Application closed` }) },
),
};
}
@@ -270,7 +270,7 @@ export function LinkApplicationDialog({ document, onClose }: LinkApplicationDial
}
};
const jobs = (applications ?? []).filter((application) => !application.archived);
const jobs = (applications ?? []).filter((application) => application.status !== "closed");
return (
<Dialog open={document !== null} onOpenChange={(open) => !open && onClose()}>
@@ -27,6 +27,8 @@ export type NewDocumentDialogData = {
step?: "copy";
/** Copy for a job: the resume to start from. */
sourceResumeId?: string;
/** Copy for a job: the application it's for. */
applicationId?: string;
/** A file dropped on the page, imported straight away. */
file?: File;
};
@@ -158,6 +160,7 @@ export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | und
<DialogContent className="sm:max-w-[640px]">
<CopyForJob
initialSourceId={data?.sourceResumeId}
initialJobId={data?.applicationId}
onBack={data?.step === "copy" ? undefined : () => setStep({ name: "choose" })}
onCreated={(resumeId) => {
markNew(resumeId);
@@ -454,20 +457,21 @@ function ImportProgress({ stage, notes }: { stage: number; notes: string[] }) {
type CopyForJobProps = {
initialSourceId?: string | undefined;
initialJobId?: string | undefined;
onBack?: (() => void) | undefined;
onCreated: (resumeId: string) => void;
};
/** Pick a resume and a job; the copy is named from both and linked to the application. */
function CopyForJob({ initialSourceId, onBack, onCreated }: CopyForJobProps) {
function CopyForJob({ initialSourceId, initialJobId, onBack, onCreated }: CopyForJobProps) {
const { i18n } = useLingui();
const nameId = useId();
const { data: documents } = useQuery(orpc.documents.list.queryOptions({ input: { trashed: false } }));
const { data: applications } = useQuery(applicationsListQueryOptions());
const resumes = (documents ?? []).filter((document) => document.type === "resume");
const jobs = (applications ?? []).filter((application) => !application.archived);
const jobs = (applications ?? []).filter((application) => application.status !== "closed");
const [sourceId, setSourceId] = useState(initialSourceId);
const [jobId, setJobId] = useState<string | null>(null);
const [jobId, setJobId] = useState<string | null>(initialJobId ?? null);
const [name, setName] = useState<string | null>(null);
const { mutateAsync: copyForJob, isPending } = useMutation(orpc.documents.copyForJob.mutationOptions());
const formatter = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
@@ -370,7 +370,7 @@ function PostingSource({ application }: { application: JobMatch["application"] }
function ApplicationPicker({ value, onChange }: { value: string | null; onChange: (id: string) => void }) {
const { data: applications } = useQuery(applicationsListQueryOptions());
const choices = (applications ?? []).filter((application) => !application.archived);
const choices = (applications ?? []).filter((application) => application.status !== "closed");
// Linking changes the document's details, which a locked document keeps as they are.
const locked = useIsResumeLocked();
@@ -12,9 +12,9 @@ const router = vi.hoisted(() => ({
vi.mock("@tanstack/react-router", () => ({
getRouteApi: () => ({
useSearch: () => router.search,
useNavigate: () => (options: { search: { mode?: string } }) =>
useNavigate: () => (options: { search: (current: typeof router.search) => { mode?: string } }) =>
new Promise<void>((resolve) => {
router.navigations.push({ mode: options.search.mode, resolve });
router.navigations.push({ mode: options.search(router.search).mode, resolve });
}),
}),
}));
@@ -17,8 +17,14 @@ export function useEditorMode() {
const setMode = useCallback(
(next: EditorMode) => {
useEditorStore.setState({ pendingMode: next });
// The builder has no other search params, so the object form is safe.
void navigate({ to: ".", search: { mode: next === "write" ? undefined : next }, replace: true }).finally(() => {
void navigate({
to: ".",
search: (current: ReturnType<typeof routeApi.useSearch>) => ({
...current,
mode: next === "write" ? undefined : next,
}),
replace: true,
}).finally(() => {
// A later pick may still be on its way; only the latest one hands over to the URL.
useEditorStore.setState((state) => (state.pendingMode === next ? { pendingMode: null } : state));
});
@@ -64,5 +64,7 @@ describe("getVersionTitle", () => {
expect(getVersionTitle({ kind: "named", name: "Sent to Lumen" })).toBe("Sent to Lumen");
expect(getVersionTitle({ kind: "import", name: null })).toBe("Imported");
expect(getVersionTitle({ kind: "auto", name: null })).toBe("Editing session");
expect(getVersionTitle({ kind: "sent", name: "Lumen Health" })).toBe("Sent to Lumen Health");
expect(getVersionTitle({ kind: "sent", name: null })).toBe("Sent");
});
});
+2 -1
View File
@@ -70,7 +70,8 @@ export function getVersionTitle(version: Pick<VersionSummary, "kind" | "name">)
case "ai":
return t`AI edit`;
case "sent":
return t`Sent`;
// Sent versions are named after the company they went to.
return version.name ? t`Sent to ${version.name}` : t`Sent`;
}
}
@@ -2,7 +2,7 @@ import type { IconName } from "@reactive-resume/ui/components/icon";
import type { EditorMode, MobileView } from "@/features/resume/editor/store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Outlet } from "@tanstack/react-router";
import { getRouteApi, Outlet } from "@tanstack/react-router";
import { useEffect, useSyncExternalStore } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Tabs, TabsContent } from "@reactive-resume/ui/components/tabs";
@@ -50,6 +50,7 @@ export function EditorShell() {
// Selection, zoom and open sheets belong to one document.
useEffect(() => resetEditor, [resetEditor]);
useOpenVersionFromUrl();
return (
<Tabs value={mode} onValueChange={(value) => setMode(value as EditorMode)} className="contents">
@@ -74,6 +75,26 @@ export function EditorShell() {
);
}
const routeApi = getRouteApi("/builder/$resumeId");
/** `?version=` opens History on that version (the page shows it read-only), then leaves the URL. */
function useOpenVersionFromUrl() {
const { version } = routeApi.useSearch();
const navigate = routeApi.useNavigate();
useEffect(() => {
if (!version) return;
const editor = useEditorStore.getState();
editor.setShareTab("history");
editor.setHistoryVersion(version);
void navigate({
to: ".",
search: (current: ReturnType<typeof routeApi.useSearch>) => ({ ...current, version: undefined }),
replace: true,
});
}, [version, navigate]);
}
function EditorHotkeys({ onModeChange }: { onModeChange: (mode: EditorMode) => void }) {
useEditorHotkeys(onModeChange);
return null;
@@ -11,6 +11,8 @@ import { EditorShell } from "./-components/editor-shell";
const searchSchema = z.object({
// Write is the default and stays out of the URL.
mode: z.enum(EDITOR_MODES).optional().catch(undefined),
// Opens History on this version, read-only; Applications' "Open" on what was sent links here.
version: z.string().optional().catch(undefined),
});
export const Route = createFileRoute("/builder/$resumeId")({
@@ -42,7 +42,7 @@ function useNavItems() {
to: "/dashboard/applications",
icon: "work",
label: t`Applications`,
...(applications ? { count: applications.filter((application) => !application.archived).length } : {}),
...(applications ? { count: applications.filter((application) => application.status !== "closed").length } : {}),
},
];
const trash: NavItem | null = counts?.trash
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
@@ -20,6 +20,7 @@ vi.mock("@/libs/orpc/client", () => ({
}));
vi.mock("@/features/applications/components/application-detail-sheet", () => ({ ApplicationDetailSheet: () => null }));
vi.mock("@/features/applications/components/application-form-sheet", () => ({ ApplicationFormSheet: () => null }));
vi.mock("@/features/applications/components/add-application-dialog", () => ({ AddApplicationDialog: () => null }));
vi.mock("@/features/applications/components/export-applications-sheet", () => ({
ExportApplicationsSheet: () => null,
}));
@@ -28,15 +29,17 @@ vi.mock("@/features/applications/components/import-applications-sheet", () => ({
}));
vi.mock("@/features/applications/components/board", () => ({ ApplicationBoard: () => null }));
vi.mock("@/features/applications/components/insights-view", () => ({ ApplicationInsights: () => null }));
vi.mock("../-components/header", () => ({ DashboardHeader: () => null }));
vi.mock("@/features/applications/components/calendar-view", () => ({ ApplicationCalendar: () => null }));
type TableProps = { applications: { id: string; company: string }[] };
vi.mock("@/features/applications/components/table-view", () => ({
ApplicationTable: ({ applications }: TableProps) => (
type ListProps = { applications: { id: string; company: string; status: string }[]; showClosed: boolean };
vi.mock("@/features/applications/components/list-view", () => ({
ApplicationList: ({ applications, showClosed }: ListProps) => (
<ul aria-label="Applications">
{applications.map((application) => (
<li key={application.id}>{application.company}</li>
))}
{applications
.filter((application) => showClosed || application.status !== "closed")
.map((application) => (
<li key={application.id}>{application.company}</li>
))}
</ul>
),
}));
@@ -47,10 +50,11 @@ beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
i18n.loadAndActivate({ locale: "en-US", messages: {} });
const base = { tags: [], contacts: [], activity: [], location: null, followUpAt: null, appliedAt: new Date() };
mocks.list.mockResolvedValue([
{ id: "one", company: "Acme", role: "Engineer", tags: [], archived: false },
{ id: "two", company: "Example", role: "Designer", tags: [], archived: false },
{ id: "archived", company: "Archived company", role: "Engineer", tags: [], archived: true },
{ ...base, id: "one", company: "Acme", role: "Engineer", status: "applied" },
{ ...base, id: "two", company: "Example", role: "Designer", status: "saved" },
{ ...base, id: "closed", company: "Closed company", role: "Engineer", status: "closed" },
]);
});
@@ -68,37 +72,29 @@ async function renderApplications(url: string) {
</I18nProvider>
</QueryClientProvider>,
);
await screen.findByPlaceholderText("Search applications…");
await screen.findByPlaceholderText("Search role, company or contact");
return router;
}
it("clears a URL-seeded search without restoring it on reload", async () => {
const router = await renderApplications(
'/dashboard/applications/?search=no-such-company&tags=["missing"]&archived=true&view=table&sort=company',
);
expect(screen.getByPlaceholderText("Search applications…")).toHaveValue("no-such-company");
await userEvent.click(screen.getByRole("button", { name: "Clear filters" }));
it("clears a URL-seeded search that matches nothing, keeping the view", async () => {
const router = await renderApplications("/dashboard/applications/?q=no-such-company&view=list&closed=true");
expect(screen.getByPlaceholderText("Search role, company or contact")).toHaveValue("no-such-company");
await userEvent.click(await screen.findByRole("button", { name: "Clear search" }));
expect(screen.getByPlaceholderText("Search applications…")).toHaveValue("");
expect(screen.getByPlaceholderText("Search role, company or contact")).toHaveValue("");
expect(await screen.findByText("Acme")).toBeVisible();
expect(screen.getByText("Example")).toBeVisible();
expect(screen.queryByText("Archived company")).not.toBeInTheDocument();
await waitFor(() => expect(router.state.location.search).toEqual({ view: "table", sort: "company" }));
const reloadUrl = router.history.location.href;
cleanup();
await renderApplications(reloadUrl);
expect(screen.getByPlaceholderText("Search applications…")).toHaveValue("");
expect(screen.getByText("Acme")).toBeVisible();
expect(screen.getByText("Example")).toBeVisible();
expect(screen.queryByRole("button", { name: "Clear filters" })).not.toBeInTheDocument();
expect(screen.getByText("Closed company")).toBeVisible();
await waitFor(() => expect(router.state.location.search).toMatchObject({ closed: true }));
});
it("filters typed company and role searches without navigating or refetching", async () => {
const router = await renderApplications("/dashboard/applications/?view=table&sort=company");
it("filters typed searches without navigating or refetching, and hides closed applications by default", async () => {
const router = await renderApplications("/dashboard/applications/");
const navigate = vi.spyOn(router, "navigate");
const url = router.history.location.href;
const input = screen.getByPlaceholderText("Search applications…");
const input = screen.getByPlaceholderText("Search role, company or contact");
expect(await screen.findByText("Acme")).toBeVisible();
expect(screen.queryByText("Closed company")).not.toBeInTheDocument();
await userEvent.type(input, "acme");
expect(screen.getByText("Acme")).toBeVisible();
@@ -1,30 +1,23 @@
import type { Application } from "@/features/applications/types";
import { msg, t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ArchiveIcon,
BriefcaseIcon,
CalendarDotsIcon,
ChartBarIcon,
DownloadSimpleIcon,
FunnelIcon,
KanbanIcon,
MagnifyingGlassIcon,
PlusIcon,
RowsIcon,
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, Link, stripSearchParams, useNavigate } from "@tanstack/react-router";
import { createFileRoute, stripSearchParams, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { Combobox } from "@/components/ui/combobox";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { AddApplicationDialog } from "@/features/applications/components/add-application-dialog";
import { ApplicationDetailSheet } from "@/features/applications/components/application-detail-sheet";
import { ApplicationFormSheet } from "@/features/applications/components/application-form-sheet";
import { ApplicationBoard } from "@/features/applications/components/board";
@@ -32,31 +25,23 @@ import { ApplicationCalendar } from "@/features/applications/components/calendar
import { ExportApplicationsSheet } from "@/features/applications/components/export-applications-sheet";
import { ImportApplicationsSheet } from "@/features/applications/components/import-applications-sheet";
import { ApplicationInsights } from "@/features/applications/components/insights-view";
import { ApplicationTable } from "@/features/applications/components/table-view";
import { ApplicationList } from "@/features/applications/components/list-view";
import { getNextStep } from "@/features/applications/next-step";
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 A–Z` },
{ value: "role", label: msg`Role A–Z` },
] as const;
type SortKey = (typeof SORT_OPTIONS)[number]["value"];
const VIEWS = ["list", "board", "insights", "calendar"] as const;
type View = (typeof VIEWS)[number];
const searchSchema = z.object({
search: z.string().default(""),
view: z.enum(["board", "table", "calendar", "insights"]).default("board"),
tags: z.array(z.string()).default([]),
sort: z.enum(["updated", "applied", "company", "role"]).default("updated"),
archived: z.boolean().default(false),
create: z.boolean().default(false),
applicationId: z.string().optional(),
q: z.string().default("").catch(""),
view: z.enum(VIEWS).default("list").catch("list"),
closed: z.boolean().default(false).catch(false),
// Deep links: open Add, or one application.
create: z.boolean().default(false).catch(false),
applicationId: z.string().optional().catch(undefined),
});
type Search = z.output<typeof searchSchema>;
const defaultSearch: Search = { search: "", view: "board", tags: [], sort: "updated", archived: false, create: false };
const defaultSearch: Search = { q: "", view: "list", closed: false, create: false };
export const Route = createFileRoute("/dashboard/applications/")({
component: RouteComponent,
@@ -64,321 +49,293 @@ export const Route = createFileRoute("/dashboard/applications/")({
search: { middlewares: [stripSearchParams(defaultSearch)] },
});
/** Search reads role, company, location, contacts and tags. */
function matches(application: Application, query: string) {
if (!query) return true;
const haystack = [
application.role,
application.company,
application.location ?? "",
...application.contacts.map((contact) => contact.name),
...application.tags,
]
.join(" ")
.toLowerCase();
return haystack.includes(query);
}
function RouteComponent() {
const { i18n } = useLingui();
const { search, view, tags, sort, archived, create, applicationId } = Route.useSearch();
const { q, view, closed, create, applicationId } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const [textSearch, setTextSearch] = useState(search);
const [addOpen, setAddOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const phone = useBreakpoint() === "mobile";
// Typing filters at once; the URL only seeds the search.
const [query, setQuery] = useState(q);
const [adding, setAdding] = useState(false);
const [importing, setImporting] = useState(false);
const [exporting, setExporting] = useState(false);
const [editing, setEditing] = useState<Application | null>(null);
const [selected, setSelected] = useState<Application | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
// Editing from the detail panel: close the panel, open the edit form on the same application.
const startEdit = (application: Application) => {
setSelected(null);
setEditing(application);
};
const { data: applications, isPending } = useQuery(applicationsListQueryOptions());
const setSearch = (patch: Partial<Search>) => void navigate({ search: (prev: Search) => ({ ...prev, ...patch }) });
useEffect(() => {
if (!create) return;
setAddOpen(true);
setAdding(true);
void navigate({ replace: true, search: (prev: Search) => ({ ...prev, create: false }) });
}, [create, navigate]);
const { data: applications, isPending } = useQuery(applicationsListQueryOptions());
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
useEffect(() => {
if (!applicationId || !applications) return;
setSelected(applications.find((application) => application.id === applicationId) ?? null);
setSelectedId(applicationId);
void navigate({ replace: true, search: (prev: Search) => ({ ...prev, applicationId: undefined }) });
}, [applicationId, applications, navigate]);
// Board & table hide archived; tag/search filters + sort are applied client-side.
const filtered = useMemo(() => {
const query = textSearch.trim().toLowerCase();
const rows = (applications ?? [])
.filter((app) => archived || !app.archived)
.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));
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, textSearch, tags, sort, archived]);
const archivedCount = (applications ?? []).filter((app) => app.archived).length;
// While loading render neither the empty state nor the board; a failed load falls back to the empty state.
const isEmpty = !applications?.length;
const setUrlSearch = (patch: Partial<Search>) => void navigate({ search: (prev: Search) => ({ ...prev, ...patch }) });
const text = query.trim().toLowerCase();
const filtered = useMemo(
() => (applications ?? []).filter((application) => matches(application, text)),
[applications, text],
);
const selected = applications?.find((application) => application.id === selectedId) ?? null;
// The board needs room to drag: phones get the list instead.
const shown: View = phone && (view === "board" || view === "calendar") ? "list" : view;
const empty = !isPending && (applications?.length ?? 0) === 0;
const noMatches = !empty && text && filtered.length === 0;
return (
<div className="flex h-dvh flex-col gap-4 p-4 max-sm:h-[calc(100dvh-52px)]">
<DashboardHeader
className="max-sm:flex-col max-sm:gap-y-3"
icon={BriefcaseIcon}
title={t`Applications`}
actions={
!isEmpty ? (
<>
<Button size="sm" variant="secondary" onClick={() => setExportOpen(true)}>
<DownloadSimpleIcon />
<Trans>Export CSV</Trans>
</Button>
<Button size="sm" variant="secondary" onClick={() => setImportOpen(true)}>
<DownloadSimpleIcon />
<Trans>Import CSV</Trans>
</Button>
<Button size="sm" onClick={() => setAddOpen(true)}>
<PlusIcon />
<Trans>Add application</Trans>
</Button>
</>
) : undefined
}
/>
<div className="mx-auto grid w-full max-w-[1180px] content-start gap-5 px-8 py-8 max-sm:px-4 max-sm:py-5">
<header className="flex flex-wrap items-center justify-between gap-3">
<h1 className="font-display font-medium text-[30px] leading-9">
<Trans>Applications</Trans>
</h1>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger
render={<Button size="icon" variant="secondary" aria-label={t`Import or export CSV`} />}
>
<Icon name="import_export" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setImporting(true)}>
<Trans>Import from CSV…</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={empty} onClick={() => setExporting(true)}>
<Trans>Export to CSV…</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button onClick={() => setAdding(true)}>
<Icon name="add" />
<Trans>Add application</Trans>
</Button>
</div>
</header>
<Separator />
{applications && <FollowUpNudge applications={applications} onOpen={setSelectedId} />}
{isPending ? null : isEmpty ? (
<EmptyState onAdd={() => setAddOpen(true)} onImport={() => setImportOpen(true)} />
{empty ? (
<EmptyState onAdd={() => setAdding(true)} onImport={() => setImporting(true)} />
) : (
<>
{/* 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>
<InputGroupInput
value={textSearch}
placeholder={t`Search applications…`}
onChange={(event) => setTextSearch(event.target.value)}
/>
</InputGroup>
{/* Desktop: filters inline. Mobile: collapsed into the Filters popover below. */}
{(allTags?.length ?? 0) > 0 && (
<Combobox
multiple
className="w-40 min-w-0 shrink max-sm:hidden"
value={tags}
placeholder={t`Filter by tags`}
options={(allTags ?? []).map((tag) => ({ value: tag, label: tag }))}
onValueChange={(value) => setUrlSearch({ tags: value ?? [] })}
/>
)}
{view !== "insights" && view !== "calendar" && (
<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 && setUrlSearch({ sort: value as SortKey })}
/>
)}
{archivedCount > 0 && view !== "insights" && (
<Button
size="sm"
variant={archived ? "primary" : "secondary"}
className="shrink-0 max-sm:hidden"
onClick={() => setUrlSearch({ archived: !archived })}
>
<ArchiveIcon />
<Trans>Archived</Trans> ({archivedCount})
</Button>
)}
{/* 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="secondary" 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) => setUrlSearch({ 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 && setUrlSearch({ sort: value as SortKey })}
/>
</div>
{archivedCount > 0 && (
<Button
size="sm"
variant={archived ? "primary" : "secondary"}
className="w-full"
onClick={() => setUrlSearch({ 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="sr-only">{i18n.t(msg`Board`)}</span>
<div className="flex flex-wrap items-center gap-2">
<Tabs value={shown} onValueChange={(value) => setSearch({ view: value as View })}>
<TabsList aria-label={t`View`}>
<TabsTrigger value="list">
<Icon name="view_agenda" size={18} />
<Trans>List</Trans>
</TabsTrigger>
<TabsTrigger
value="table"
title={i18n.t(msg`Table`)}
nativeButton={false}
render={<Link to="." search={(p: Search) => ({ ...p, view: "table" })} />}
>
<RowsIcon />
<span className="sr-only">{i18n.t(msg`Table`)}</span>
</TabsTrigger>
<TabsTrigger
value="calendar"
title={i18n.t(msg`Calendar`)}
nativeButton={false}
render={<Link to="." search={(p: Search) => ({ ...p, view: "calendar" })} />}
>
<CalendarDotsIcon />
<span className="sr-only">{i18n.t(msg`Calendar`)}</span>
</TabsTrigger>
<TabsTrigger
value="insights"
title={i18n.t(msg`Insights`)}
nativeButton={false}
render={<Link to="." search={(p: Search) => ({ ...p, view: "insights" })} />}
>
<ChartBarIcon />
<span className="sr-only">{i18n.t(msg`Insights`)}</span>
{!phone && (
<TabsTrigger value="board">
<Icon name="view_kanban" size={18} />
<Trans>Board</Trans>
</TabsTrigger>
)}
<TabsTrigger value="insights">
<Icon name="insights" size={18} />
<Trans>Insights</Trans>
</TabsTrigger>
{!phone && (
<TabsTrigger value="calendar">
<Icon name="calendar_month" size={18} />
<Trans>Calendar</Trans>
</TabsTrigger>
)}
</TabsList>
</Tabs>
</div>
<div className="flex min-h-0 flex-1 flex-col">
{view !== "insights" && filtered.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
<p className="font-medium text-sm">
<Trans>No applications match your filters.</Trans>
</p>
<Button
size="sm"
variant="secondary"
onClick={() => {
setTextSearch("");
setUrlSearch({ search: "", tags: [], archived: false });
}}
>
<Trans>Clear filters</Trans>
</Button>
</div>
) : (
<>
{view === "board" && (
<ApplicationBoard applications={filtered} onOpen={setSelected} onEdit={setEditing} />
)}
{view === "table" && (
<ApplicationTable applications={filtered} onOpen={setSelected} onEdit={setEditing} />
)}
{view === "calendar" && (
<ApplicationCalendar
applications={filtered}
allApplications={applications ?? []}
onOpen={setSelected}
/>
)}
{view === "insights" && <ApplicationInsights applications={applications ?? []} />}
</>
<div className="relative min-w-40 max-w-72 flex-1">
<Icon
name="search"
size={18}
className="pointer-events-none absolute start-2.5 top-1/2 -translate-y-1/2 text-ink-3"
/>
<Input
type="search"
aria-label={t`Search applications`}
placeholder={t`Search role, company or contact`}
value={query}
className="ps-8"
onChange={(event) => setQuery(event.target.value)}
/>
</div>
{shown !== "insights" && (
<Button
variant="secondary"
aria-pressed={closed}
onClick={() => setSearch({ closed: !closed })}
className={cn(closed && "bg-sunken")}
>
<Icon name={closed ? "check_box" : "check_box_outline_blank"} size={18} />
<Trans>Show closed</Trans>
</Button>
)}
</div>
{noMatches ? (
<div className="grid justify-items-center gap-2 py-16 text-center">
<p className="font-medium text-sm">
<Trans>No applications match “{query.trim()}”.</Trans>
</p>
<Button size="sm" variant="secondary" onClick={() => setQuery("")}>
<Trans>Clear search</Trans>
</Button>
</div>
) : (
<>
{shown === "list" && (
<ApplicationList
applications={filtered}
showClosed={closed}
selectedId={selectedId}
onOpen={(application) => setSelectedId(application.id)}
/>
)}
{shown === "board" && (
<div className="h-[calc(100svh-230px)] min-h-96">
<ApplicationBoard
applications={filtered}
showClosed={closed}
onOpen={(application) => setSelectedId(application.id)}
/>
</div>
)}
{shown === "insights" && <ApplicationInsights applications={applications ?? []} />}
{shown === "calendar" && (
<ApplicationCalendar
applications={filtered.filter((application) => closed || application.status !== "closed")}
allApplications={applications ?? []}
onOpen={(application) => setSelectedId(application.id)}
/>
)}
</>
)}
</>
)}
<ApplicationFormSheet open={addOpen} onOpenChange={setAddOpen} />
<ApplicationFormSheet open={!!editing} application={editing} onOpenChange={(open) => !open && setEditing(null)} />
<ImportApplicationsSheet open={importOpen} onOpenChange={setImportOpen} />
<AddApplicationDialog open={adding} onOpenChange={setAdding} onAdded={setSelectedId} />
<ApplicationFormSheet
open={Boolean(editing)}
application={editing}
onOpenChange={(open) => !open && setEditing(null)}
/>
<ImportApplicationsSheet open={importing} onOpenChange={setImporting} />
<ExportApplicationsSheet
open={exportOpen}
onOpenChange={setExportOpen}
open={exporting}
onOpenChange={setExporting}
applications={applications ?? []}
filtered={filtered}
/>
<ApplicationDetailSheet
application={selected}
onOpenChange={(open) => !open && setSelected(null)}
onEdit={startEdit}
onOpenChange={(open) => !open && setSelectedId(null)}
onEditDetails={(application) => {
setSelectedId(null);
setEditing(application);
}}
/>
</div>
);
}
const NUDGE_STORAGE_KEY = "applications-follow-up-dismissed";
function readDismissed(): string[] {
try {
return JSON.parse(window.localStorage.getItem(NUDGE_STORAGE_KEY) ?? "[]") as string[];
} catch {
return [];
}
}
/**
* The follow-up nudge: the application waiting longest without a reply (10+ days since applying). Dismissing it is
* remembered on this device; it doesn't come back for that application.
*/
function FollowUpNudge({ applications, onOpen }: { applications: Application[]; onOpen: (id: string) => void }) {
const [dismissed, setDismissed] = useState(readDismissed);
const waiting = applications
.filter((application) => !dismissed.includes(application.id))
.flatMap((application) => {
const step = getNextStep(application);
return step.kind === "no-reply" ? [{ application, days: step.days }] : [];
})
.sort((a, b) => b.days - a.days)[0];
if (!waiting) return null;
const dismiss = () => {
const next = [...dismissed, waiting.application.id].slice(-200);
setDismissed(next);
try {
window.localStorage.setItem(NUDGE_STORAGE_KEY, JSON.stringify(next));
} catch {
// Without storage the nudge is dismissed for this visit only.
}
};
const { company } = waiting.application;
const { days } = waiting;
return (
<div
role="status"
className="flex flex-wrap items-center gap-2 rounded-xl bg-warn-soft px-3 py-2 text-sm text-warn-text"
>
<Icon name="schedule" size={20} />
<span className="min-w-0 flex-1">
<Trans>
{company}: no reply for {days} days. A short follow-up is usually fine now.
</Trans>
</span>
<Button size="sm" variant="secondary" onClick={() => onOpen(waiting.application.id)}>
<Trans>Open</Trans>
</Button>
<Button size="icon-sm" variant="ghost" aria-label={t`Dismiss`} onClick={dismiss} className="text-warn-text">
<Icon name="close" size={18} />
</Button>
</div>
);
}
function EmptyState({ onAdd, onImport }: { onAdd: () => void; onImport: () => void }) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<BriefcaseIcon className="size-7 text-muted-foreground" />
</div>
<div className="max-w-md space-y-1.5">
<h2 className="font-semibold text-lg">
<Trans>Track your first application</Trans>
</h2>
<p className="text-muted-foreground text-sm">
<Trans>
Add a job you're applying to and link the resume you sent. Move it across the board as you hear back.
</Trans>
</p>
</div>
<div className="grid justify-items-center gap-3 py-20 text-center">
<span className="grid size-12 place-items-center rounded-xl bg-sunken text-ink-2">
<Icon name="work" size={26} />
</span>
<h2 className="font-semibold text-lg">
<Trans>Track your first job</Trans>
</h2>
<p className="max-w-sm text-ink-2 text-sm">
<Trans>Paste a job link. We'll save the posting so Check, the assistant and your letter can use it.</Trans>
</p>
<div className="flex gap-2">
<Button onClick={onAdd}>
<PlusIcon />
<Icon name="add" />
<Trans>Add application</Trans>
</Button>
<Button variant="secondary" onClick={onImport}>
<DownloadSimpleIcon />
<Trans>Import from CSV</Trans>
</Button>
</div>
@@ -13,6 +13,8 @@
"calendar_month",
"call",
"check",
"check_box",
"check_box_outline_blank",
"check_circle",
"chevron_left",
"chevron_right",
@@ -46,6 +48,7 @@
"format_size",
"grid_view",
"history",
"hourglass_empty",
"hub",
"import_export",
"info",
@@ -100,6 +103,8 @@
"upload_file",
"vertical_align_center",
"vertical_split",
"view_agenda",
"view_kanban",
"view_list",
"visibility",
"visibility_off",
Binary file not shown.
+5
View File
@@ -18,6 +18,8 @@ export const iconNames = [
"calendar_month",
"call",
"check",
"check_box",
"check_box_outline_blank",
"check_circle",
"chevron_left",
"chevron_right",
@@ -51,6 +53,7 @@ export const iconNames = [
"format_size",
"grid_view",
"history",
"hourglass_empty",
"hub",
"import_export",
"info",
@@ -105,6 +108,8 @@ export const iconNames = [
"upload_file",
"vertical_align_center",
"vertical_split",
"view_agenda",
"view_kanban",
"view_list",
"visibility",
"visibility_off",