import Link from "next/link"; import { BellIcon, CalendarDaysIcon, CheckSquareIcon, ChevronLeftIcon, ChevronRightIcon, HandshakeIcon, HistoryIcon, UsersIcon, } from "lucide-react"; import type { Stage } from "@prisma/client"; import { STAGE_LABEL, STAGE_TONE } from "@/lib/stages"; import type { ScheduleKind } from "@/lib/data/pipeline"; import { cn } from "@/lib/utils"; import { clockIn, SERVER_ZONE } from "@/lib/time"; export type CalendarEntry = { kind: ScheduleKind; id: string; /** ISO day, YYYY-MM-DD, already in the month being shown. */ day: string; title: string; /** The role, or the person's title — whatever the title does not already say. */ detail: string; /** Null for anything that is not an application. */ stage: Stage | null; applicationId: string | null; contactId: string | null; done: boolean | null; /** A MEETING with nothing on the pipeline to open goes to Google Calendar instead. */ url?: string; }; /** * A month grid of everything with a date on it. * * Deliberately not a real calendar: no dragging, no creating by clicking an * empty square, no week or day zoom. Dates in this product are set by the work * — moving a stage schedules the follow-up — so the calendar's job is to answer * "what is coming" and hand you a link, not to be a second place to edit. */ const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; /** * Parse `YYYY-MM`, falling back to the current month. * * `timeZone` decides which month "current" is — on the last day of a month the * server and the reader can disagree about it. The grid itself is drawn in UTC * and stays that way: a month has the same shape everywhere, and only the two * zone-sensitive questions — which cell an entry lands in, and which cell is * today — are answered from the reader's calendar. */ export function parseMonth( value: string | undefined, timeZone: string = SERVER_ZONE, ): { year: number; month: number } { const match = /^(\d{4})-(\d{2})$/.exec(value ?? ""); const here = clockIn(new Date(), timeZone); const current = { year: here.year, month: here.month - 1 }; if (!match) return current; const month = Number(match[2]) - 1; if (month < 0 || month > 11) return current; return { year: Number(match[1]), month }; } /** The window a month grid actually shows: Monday before the 1st to Sunday after the last. */ export function monthWindow(year: number, month: number) { const first = new Date(Date.UTC(year, month, 1)); const last = new Date(Date.UTC(year, month + 1, 0)); const lead = (first.getUTCDay() + 6) % 7; // Monday-first const from = new Date(Date.UTC(year, month, 1 - lead)); const trail = 6 - ((last.getUTCDay() + 6) % 7); const to = new Date(Date.UTC(year, month + 1, trail)); to.setUTCHours(23, 59, 59, 999); return { from, to }; } const KIND_TONE: Record = { FOLLOW_UP: "var(--warning)", TASK: "var(--primary)", ACTIVITY: "var(--stage-3)", MEETING: "var(--stage-interview)", INTERVIEW: "var(--stage-interview)", OFFER: "var(--stage-offer)", }; const KIND_LABEL: Record = { FOLLOW_UP: "Follow-up due", TASK: "Task due", ACTIVITY: "Logged", MEETING: "On your calendar", INTERVIEW: "Interview", OFFER: "Answer an offer by", }; /** * A shape per kind, so the six kinds are not told apart by colour alone. * * Two of the six were already near-identical dots — MEETING and INTERVIEW share * `--stage-interview` — so even with perfect colour vision the legend was the * only way to read them apart, and the legend is at the top of a grid you have * scrolled away from. The dot stays as the colour cue; the icon is what makes * the kind legible on the chip itself. */ const KIND_ICON: Record = { FOLLOW_UP: BellIcon, TASK: CheckSquareIcon, ACTIVITY: HistoryIcon, MEETING: UsersIcon, INTERVIEW: CalendarDaysIcon, OFFER: HandshakeIcon, }; export function PipelineCalendar({ year, month, entries, today, fields, }: { year: number; month: number; entries: CalendarEntry[]; /** Passed in from the server so the grid and the highlight agree. */ today: string; /** * What a chip shows besides its title. A chip has room for very little, so * the catalogue is two things — everything else on a schedule entry is * already inside the title and would print the same words twice. */ fields: string[]; }) { const shows = new Set(fields); const { from } = monthWindow(year, month); const byDay = new Map(); for (const entry of entries) { const bucket = byDay.get(entry.day); if (bucket) bucket.push(entry); else byDay.set(entry.day, [entry]); } // In the array's own order, so the legend reads the same every month rather // than reshuffling with whatever happened to be scheduled first. const seen = new Set(entries.map((entry) => entry.kind)); const present = (Object.keys(KIND_LABEL) as ScheduleKind[]).filter((kind) => seen.has(kind)); const cells = Array.from({ length: 42 }, (_, i) => { const date = new Date(from); date.setUTCDate(from.getUTCDate() + i); return date; }).filter( // Six rows only when the month genuinely needs them. (_, i, all) => i < 35 || all.slice(35).some((d) => d.getUTCMonth() === month), ); const label = new Date(Date.UTC(year, month, 1)).toLocaleDateString("en-US", { month: "long", year: "numeric", timeZone: "UTC", }); return (

{label}

{/* Paging away from the current month is easy; paging back to it by hand is not. */} Today
{/* Only the kinds this month actually contains. Six fixed entries was a line of chrome as long as the month header, most of it explaining colours nothing on screen was using — and in an empty month it was the only thing on the card with any ink in it. */} {present.length > 0 && (
{present.map((kind) => { const Icon = KIND_ICON[kind]; return ( {KIND_LABEL[kind]} ); })}
)}
{/* Seven columns in 360px is 48px a cell, which is not a calendar — it is a grid of three-character stubs. The month keeps a usable width and scrolls sideways instead, the same gesture the board already uses. */}
{WEEKDAYS.map((day) => (
{day}
))}
{cells.map((date) => { const iso = date.toISOString().slice(0, 10); const outside = date.getUTCMonth() !== month; const items = byDay.get(iso) ?? []; return (
{date.getUTCDate()}
{items.slice(0, 3).map((entry) => ( ))} {/* "+3 more" used to be a dead end: the busiest days in the month were the ones you could not read. A details element opens the rest in place — no client JavaScript, which this server component has none of, and it stays keyboard and screen-reader operable for free. */} {items.length > 3 && (
{items.slice(3).map((entry) => ( ))}
)}
); })}
); } function CalendarChip({ entry, fields }: { entry: CalendarEntry; fields: Set }) { const Icon = KIND_ICON[entry.kind]; // What the chip cannot show. It used to repeat the title, which is the one // thing already on screen — so hovering a truncated row told you nothing. const tooltip = [ KIND_LABEL[entry.kind], entry.title, entry.detail, entry.stage ? STAGE_LABEL[entry.stage] : "", entry.done ? "done" : "", ] .filter(Boolean) .join(" · "); const body = ( {/* The title takes the room and the detail gets what is left, rather than the two splitting a 130px cell evenly and truncating each other into six characters apiece — which is what "Applie… Wishlist…" was. The detail waits for a genuinely wide screen; below that the tooltip carries it, which is why the tooltip now names the kind and the stage instead of repeating the title. */} {entry.title} {fields.has("detail") && entry.detail && ( {entry.detail} )} {fields.has("stage") && entry.stage && ( {STAGE_LABEL[entry.stage]} )} ); const href = entry.applicationId ? `/applications/${entry.applicationId}` : entry.contactId ? `/crm/contacts/${entry.contactId}` : null; if (!href && entry.url) { return ( {body} ); } return href ? ( {body} ) : (
{body}
); } function MonthLink({ year, month, children, ...props }: { year: number; month: number; children: React.ReactNode; "aria-label": string; }) { // Normalise an out-of-range month into the neighbouring year. const date = new Date(Date.UTC(year, month, 1)); const target = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`; return ( {children} ); }