"use client"; import Link from "next/link"; import { createContext, useContext, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { DndContext, DragOverlay, MouseSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors, type DragEndEvent, type DragStartEvent, } from "@dnd-kit/core"; import { AnimatePresence, motion } from "framer-motion"; import { BuildingIcon, HandshakeIcon, CalendarClockIcon, ChevronsLeftRightIcon, ChevronsRightLeftIcon, FileTextIcon, MapPinIcon, MessageSquareIcon, MoonIcon, } from "lucide-react"; import { toast } from "sonner"; import type { Stage } from "@prisma/client"; import { BOARD_STAGES, STAGE_LABEL, STAGE_TONE, TERMINAL_STAGES } from "@/lib/stages"; import { SHOW_QUIET_AFTER, STALE_AFTER, hasGoneQuiet } from "@/lib/quiet"; import { ApplicationActions } from "@/components/pipeline/application-actions"; import { CompanyAvatar } from "@/components/pipeline/company-avatar"; import { TagChip } from "@/components/tags/tag-chip"; import { formatMoney } from "@/components/pipeline/offer-card"; import { useOpenApplication } from "@/components/pipeline/application-panel"; import { cn, describeAdded, relativeDay } from "@/lib/utils"; import { useViewerZone } from "@/components/viewer-zone"; import { moveStageAction } from "@/server/actions"; /** Which columns you have folded away. A preference, not a filter. */ const COLLAPSED_KEY = "hired:board-collapsed"; export type Card = { id: string; company: string; roleTitle: string; stage: Stage; location: string; salaryRange: string; /** * What they actually offered, once somebody has. Replaces the advertised * range on the card when it exists: a real number beats a posting's range, * and showing both would make the card ask you which one is true. */ offer: { total: number; currency: string } | null; nextFollowUpAt: string | null; resumeName: string | null; activityCount: number; /** Days since anything at all happened here. */ quietDays: number; /** The posting, for the card's own actions. Empty when there never was one. */ jobUrl: string; /** Null when logos are off, or no domain could be worked out. */ domain: string | null; /** Off by default on a card: a board is scanned, not read. */ tags: { id: string; name: string; color: string }[]; }; /** * Which optional fields a card draws, as context rather than a prop. * * A card is four components deep — board, column, draggable, card — and * threading a set through all four to be read only at the bottom is four * signatures changed for one value that never varies within a render. */ const VisibleFields = createContext>(new Set()); const useVisibleFields = () => useContext(VisibleFields); export function PipelineBoard({ open, closed, // Which columns to draw. Filtering to one stage should show that column on // its own rather than five empty ones beside it. columns = BOARD_STAGES, fields, }: { open: Card[]; closed: Card[]; columns?: Stage[]; /** What each card shows. Every key of BOARD_FIELDS that is turned on. */ fields: string[]; }) { const shows = useMemo(() => new Set(fields), [fields]); const openPanel = useOpenApplication(); const [cards, setCards] = useState(open); const [dragging, setDragging] = useState(null); const [, startTransition] = useTransition(); // Mouse and touch are separated deliberately. One PointerSensor covered both, // and on a phone every gesture is a pointer gesture: a 6px movement is the // start of a scroll far more often than the start of a drag, so the board // grabbed a card whenever you tried to scroll past it and the column could // not be scrolled at all. Touch now needs a 220ms hold before a card moves, // which is the gesture a person already expects for "pick this up", and a // swipe below that threshold scrolls the way it should. Tapping a card still // opens the panel, where the stage Select moves it without any dragging. const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 6 } }), useSensor(TouchSensor, { activationConstraint: { delay: 220, tolerance: 8 } }), ); // Which stages are folded to a spine. Kept in localStorage rather than the // URL: it is how you like to look at the board, not what you are looking at, // so it should survive a navigation and should not travel in a shared link. // Read after mount so the server and the first client render agree. const [collapsed, setCollapsed] = useState>(new Set()); useEffect(() => { try { const saved = window.localStorage.getItem(COLLAPSED_KEY); if (saved) setCollapsed(new Set(JSON.parse(saved) as Stage[])); } catch { // A malformed or unreadable value just means no collapsed columns. } }, []); const toggleColumn = (stage: Stage) => setCollapsed((prev) => { const next = new Set(prev); if (next.has(stage)) next.delete(stage); else next.add(stage); try { window.localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...next])); } catch { // Private mode: the preference just does not persist. } return next; }); // Re-seed when the filter changes: `open` is a prop, and useState only reads // its initial value, so without this the board keeps showing the last cut. const [seed, setSeed] = useState(open); if (seed !== open) { setSeed(open); setCards(open); } const byStage = useMemo(() => { const map = new Map(); for (const stage of columns) map.set(stage, []); for (const card of cards) map.get(card.stage)?.push(card); return map; }, [cards, columns]); const onDragStart = (event: DragStartEvent) => { setDragging(cards.find((card) => card.id === event.active.id) ?? null); }; const onDragEnd = (event: DragEndEvent) => { setDragging(null); const target = event.over?.id as Stage | undefined; const id = String(event.active.id); if (!target) return; const card = cards.find((item) => item.id === id); if (!card || card.stage === target) return; // Optimistic: the card lands where you dropped it before the server replies. setCards((prev) => prev.map((item) => (item.id === id ? { ...item, stage: target } : item))); startTransition(async () => { try { const { addedTasks } = await moveStageAction(id, target); toast.success(`${card.company} → ${STAGE_LABEL[target]}`, { description: describeAdded(addedTasks), }); } catch { setCards((prev) => prev.map((item) => (item.id === id ? { ...item, stage: card.stage } : item)), ); toast.error("Could not move that card."); } }); }; return (
setDragging(null)} > {columns.length > 0 && (
{columns.map((stage) => ( toggleColumn(stage)} /> ))}
)} {dragging && (
)}
{closed.length > 0 && (

Closed · {closed.length}

{closed.map((card) => ( { if (!openPanel || event.metaKey || event.ctrlKey || event.shiftKey) return; event.preventDefault(); openPanel(card.id); }} >
{card.company}
{card.roleTitle}
{STAGE_LABEL[card.stage]}
))}
)}
); } function Column({ stage, cards, collapsed, onToggle, }: { stage: Stage; cards: Card[]; collapsed: boolean; onToggle: () => void; }) { const { setNodeRef, isOver } = useDroppable({ id: stage }); // A collapsed column is still a drop target — the reason to collapse a stage // is that you are not working it, not that nothing may ever land there, and // a card dragged onto the spine should go in rather than snap back. if (collapsed) { return (
{cards.length} {STAGE_LABEL[stage]}
); } return (
{STAGE_LABEL[stage]} {cards.length}
{cards.map((card) => ( ))} {cards.length === 0 && (
{isOver ? "Drop here" : "Empty"}
)}
); } function DraggableCard({ card }: { card: Card }) { // tabIndex -1 on the draggable, because the card inside it is already a // link and therefore already focusable. Without this every card is two tab // stops, the outer one announced as a draggable button that no registered // sensor can actually drag — the sensors below are mouse and touch only. const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: card.id, attributes: { tabIndex: -1 }, }); // A drop is followed by a click on the element that was dragged — the // browser fires it on the pointerup regardless. Without this guard, filing a // card away would then open the panel for it. Armed during the drag, spent // on the click that follows, and re-cleared on the next press so a cancelled // drag (Escape) cannot leave it swallowing a genuine click later. const wasDragged = useRef(false); if (isDragging) wasDragged.current = true; return ( { wasDragged.current = false; }} // Enter on the card's link synthesizes a click with no pointerdown, so a // drag that ended off the card (its click landed elsewhere) would leave // the guard armed and eat the next keyboard activation. onKeyDownCapture={() => { wasDragged.current = false; }} onClickCapture={(event) => { if (!wasDragged.current) return; wasDragged.current = false; event.preventDefault(); event.stopPropagation(); }} // touch-none here meant "the browser may not scroll from this element", // and since the cards cover the board, that disabled scrolling over the // whole thing — vertically as well as sideways. touch-manipulation gives // scrolling back and still suppresses double-tap zoom; the TouchSensor's // 220ms hold is what distinguishes a drag now, so the browser no longer // has to be locked out to make dragging possible. select-none stops the // hold from starting a text selection instead of picking the card up. // The card is an anchor now, and iOS Safari answers a still ~500ms press // on a link with its native preview sheet — which fires touchcancel and // kills the drag the 220ms hold just started. touch-callout is the one // switch that suppresses it (contextmenu never fires for touch on iOS), // and it inherits down to the anchor from here. className="touch-manipulation select-none [-webkit-touch-callout:none]" > ); } function ApplicationCard({ card, overlay = false }: { card: Card; overlay?: boolean }) { const zone = useViewerZone(); const shows = useVisibleFields(); const overdue = card.nextFollowUpAt ? new Date(card.nextFollowUpAt) < new Date() : false; const openPanel = useOpenApplication(); // The WHOLE card is the way in, not just the company's name. Opening a card // is what you do to it far more often than dragging it, so the click target // is the full surface: a plain click opens the panel, cmd/ctrl/shift-click // opens the page in a new tab, and a press that moves 6px is still a drag — // the sensors' activation thresholds are what keep the two gestures apart. const className = cn( "group bg-card relative block overflow-hidden rounded-card p-3 pr-8 pl-3.5 transition-shadow duration-200 ease-[var(--ease-settle)]", // A stripe of the stage's colour down the edge, so a column reads as // one thing at a glance and a mis-dropped card is obvious. "before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-[var(--tone)]", // The link is a transparent overlay, so the focus ring has to be drawn by // the card around it or keyboard focus would be invisible. "has-[a:focus-visible]:ring-2 has-[a:focus-visible]:ring-[var(--ring)] has-[a:focus-visible]:outline-none", overlay ? "shadow-overlay" : "shadow-card hover:shadow-raised cursor-pointer active:cursor-grabbing", ); const tone = { ["--tone" as string]: STAGE_TONE[card.stage] }; const body = ( <>
{card.company}
{card.roleTitle}
{((shows.has("location") && card.location) || (shows.has("salary") && (card.offer || card.salaryRange)) || (shows.has("tags") && card.tags.length > 0)) && (
{shows.has("location") && card.location && ( {card.location} )} {shows.has("salary") && (card.offer ? ( {formatMoney(card.offer.total, card.offer.currency)} ) : ( card.salaryRange && ( {card.salaryRange} ) ))} {shows.has("tags") && card.tags.map((tag) => )}
)}
{shows.has("followUp") && card.nextFollowUpAt && ( {relativeDay(new Date(card.nextFollowUpAt), zone)} )} {/* How long since anything happened, which is the question the board existed to answer and could not. Below a week it is noise, so it says nothing; past the stage's own threshold it turns the colour of the diagnosis that agrees with it. */} {shows.has("quiet") && card.quietDays >= SHOW_QUIET_AFTER && STALE_AFTER[card.stage] !== undefined && ( {card.quietDays}d quiet )}
{shows.has("resume") && card.resumeName && } {shows.has("activity") && card.activityCount > 0 && ( {card.activityCount} )}
); // The overlay copy that follows the pointer is decoration, not a control. if (overlay) { return (
{body}
); } // The card is a div with a stretched link inside rather than a link wrapping // everything, because the actions menu is a button and a button inside an // anchor is invalid — the same arrangement the contacts list uses. The // overlay keeps the whole surface clickable; the menu is positioned, so it // paints above the overlay and stays clickable in its own right. return (
{ if (!openPanel || event.metaKey || event.ctrlKey || event.shiftKey) return; event.preventDefault(); openPanel(card.id); }} className="absolute inset-0 z-0" />
{body}
); } export { TERMINAL_STAGES };