src/components/pipeline/calendar.tsx
Rama main · réplica de lectura del repositorio original.
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<ScheduleKind, string> = {
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<ScheduleKind, string> = {
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<ScheduleKind, typeof BellIcon> = {
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<string, CalendarEntry[]>();
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 (
<div className="bg-card shadow-card overflow-hidden rounded-xl">
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5">
<h2 className="text-[14px] font-semibold">{label}</h2>
<div className="flex items-center gap-1">
<MonthLink year={year} month={month - 1} aria-label="Previous month">
<ChevronLeftIcon className="size-3.5" />
</MonthLink>
<MonthLink year={year} month={month + 1} aria-label="Next month">
<ChevronRightIcon className="size-3.5" />
</MonthLink>
{/* Paging away from the current month is easy; paging back to it by
hand is not. */}
<Link
href="/applications?view=calendar"
className="text-muted-foreground hover:text-foreground hover:bg-accent flex h-11 items-center rounded-chip px-2 text-[12px] font-medium transition-colors duration-150 md:h-6"
>
Today
</Link>
</div>
{/* 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 && (
<div className="text-faint ml-auto flex flex-wrap items-center gap-3 text-[11.5px]">
{present.map((kind) => {
const Icon = KIND_ICON[kind];
return (
<span key={kind} className="flex items-center gap-1">
<Icon className="size-3" style={{ color: KIND_TONE[kind] }} />
{KIND_LABEL[kind]}
</span>
);
})}
</div>
)}
</div>
{/* 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. */}
<div className="no-scrollbar overflow-x-auto">
<div className="min-w-[44rem] md:min-w-0">
<div className="eyebrow bg-inset grid grid-cols-7">
{WEEKDAYS.map((day) => (
<div key={day} className="px-2 py-1.5">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 border-t">
{cells.map((date) => {
const iso = date.toISOString().slice(0, 10);
const outside = date.getUTCMonth() !== month;
const items = byDay.get(iso) ?? [];
return (
<div
key={iso}
className={cn(
"min-h-[6.5rem] border-r border-b p-1.5 [&:nth-child(7n)]:border-r-0",
outside && "bg-inset",
)}
>
<div
className={cn(
"nums mb-1 flex size-5 items-center justify-center rounded-full text-[11.5px]",
iso === today && "bg-foreground text-background font-semibold",
iso !== today && (outside ? "text-faint/60" : "text-muted-foreground"),
)}
>
{date.getUTCDate()}
</div>
<div className="space-y-0.5">
{items.slice(0, 3).map((entry) => (
<CalendarChip
key={`${entry.kind}-${entry.id}`}
entry={entry}
fields={shows}
/>
))}
{/* "+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 && (
<details className="group/more">
<summary // list-none handles Firefox and Chrome; Safari needs the webkit marker
// hidden separately or the disclosure triangle stays.
className="text-muted-foreground hover:text-foreground hover:bg-accent rounded-chip cursor-pointer list-none px-1 py-0.5 text-[11px] transition-colors duration-150 [&::-webkit-details-marker]:hidden">
<span className="group-open/more:hidden">+{items.length - 3} more</span>
<span className="hidden group-open/more:inline">Show less</span>
</summary>
<div className="mt-0.5 space-y-0.5">
{items.slice(3).map((entry) => (
<CalendarChip
key={`${entry.kind}-${entry.id}`}
entry={entry}
fields={shows}
/>
))}
</div>
</details>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
function CalendarChip({ entry, fields }: { entry: CalendarEntry; fields: Set<string> }) {
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 = (
<span
className={cn(
// Tall enough for a finger below md; back to the dense row above it.
"flex min-h-11 items-center gap-1 truncate rounded-chip px-1 py-0.5 text-[11.5px] md:min-h-0",
entry.done ? "text-faint line-through" : "text-foreground",
)}
>
<Icon
className="size-3 shrink-0"
style={{ color: KIND_TONE[entry.kind] }}
aria-hidden
/>
{/* 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. */}
<span className="min-w-0 flex-1 truncate">{entry.title}</span>
{fields.has("detail") && entry.detail && (
<span className="text-faint hidden max-w-[45%] shrink truncate 2xl:inline">
{entry.detail}
</span>
)}
{fields.has("stage") && entry.stage && (
<span
className="stage-chip hidden shrink-0 rounded-chip px-1 text-[10px] font-medium lg:inline"
style={{ ["--tone" as string]: STAGE_TONE[entry.stage] }}
>
{STAGE_LABEL[entry.stage]}
</span>
)}
</span>
);
const href = entry.applicationId
? `/applications/${entry.applicationId}`
: entry.contactId
? `/crm/contacts/${entry.contactId}`
: null;
if (!href && entry.url) {
return (
<a
href={entry.url}
target="_blank"
rel="noreferrer noopener"
title={tooltip}
className="hover:bg-accent block rounded-chip transition-colors duration-150"
>
{body}
</a>
);
}
return href ? (
<Link
href={href}
title={tooltip}
className="hover:bg-accent block rounded-chip transition-colors duration-150"
>
{body}
</Link>
) : (
<div title={tooltip}>{body}</div>
);
}
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 (
<Link
href={`/applications?view=calendar&month=${target}`}
className="text-muted-foreground hover:text-foreground hover:bg-accent touch-target flex size-11 items-center justify-center rounded-chip md:size-6 transition-colors duration-150"
{...props}
>
{children}
</Link>
);
}