Skip to content
138 linesCodeBlameRaw
1/**
2 * A person's last year on their profile: a square a day, a column a week,
3 * shaded in the accent by how much they did (commits pushed to a default
4 * branch, issues and pull requests opened, reviews given) on repositories
5 * the viewer can see. The layout is
6 * lib/contribution-calendar.ts; the counts come from the work service's
7 * `contributions`, which never counts work the viewer could not open.
8 *
9 * One hint serves every square: it follows the pointer, so a year of days
10 * costs one tooltip, not 365.
11 */
12import { type PointerEvent, useEffect, useMemo, useRef, useState } from "react";
13
14import { type Level, buildCalendar, dayLabel, totalLabel } from "../lib/contribution-calendar";
15import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
16
17/** A square's side and the space between squares, in pixels. */
18const CELL = 10;
19const GAP = 3;
20const STEP = CELL + GAP;
21
22/** Each level's shade: the raised gray for nothing, then the accent, stronger with more. */
23const SHADES: Record<Level, string> = {
24 0: "bg-raised",
25 1: "bg-accent/25",
26 2: "bg-accent/45",
27 3: "bg-accent/70",
28 4: "bg-accent",
29};
30
31const WEEKDAYS = ["", "Mon", "", "Wed", "", "Fri", ""];
32
33type Hovered = { date: string; count: number; commits: number; left: number; top: number };
34
35export function ContributionCalendar({
36 days,
37 total,
38 today,
39 from,
40}: {
41 days: readonly { date: string; count: number; commits?: number }[];
42 total: number;
43 /** `YYYY-MM-DD`, UTC: the last day shown. */
44 today: string;
45 /** The first day counted; a year before `today` when absent. */
46 from?: string;
47}) {
48 const calendar = useMemo(() => buildCalendar(days, today, from), [days, today, from]);
49 const scroller = useRef<HTMLDivElement>(null);
50 const [hovered, setHovered] = useState<Hovered | null>(null);
51
52 // On a narrow screen the calendar scrolls inside its card; start at today.
53 useEffect(() => {
54 const element = scroller.current;
55 if (element) element.scrollLeft = element.scrollWidth;
56 }, []);
57
58 const over = (event: PointerEvent<HTMLDivElement>) => {
59 const cell = (event.target as HTMLElement).closest<HTMLElement>("[data-date]");
60 if (!cell) return setHovered(null);
61 const date = cell.dataset.date!;
62 if (hovered?.date === date) return;
63 setHovered({ date, count: Number(cell.dataset.count ?? 0), commits: Number(cell.dataset.commits ?? 0), left: cell.offsetLeft, top: cell.offsetTop });
64 };
65
66 const heading = totalLabel(total);
67 return (
68 <section aria-label="Contributions" className="rounded-xl border border-line bg-surface/50 px-4 pt-3.5 pb-3">
69 <h2 className="text-sm font-medium">{heading}</h2>
70 <div ref={scroller} className="mt-3 overflow-x-auto overflow-y-hidden pb-1 [scrollbar-width:thin]">
71 <div className="flex w-max text-[0.625rem] leading-none text-faint">
72 {/* The weekdays, under the months' row. */}
73 <div aria-hidden="true" className="mr-1.5 flex shrink-0 flex-col" style={{ gap: GAP, paddingTop: 16 }}>
74 {WEEKDAYS.map((label, index) => (
75 <span key={index} className="flex items-center" style={{ height: CELL }}>
76 {label}
77 </span>
78 ))}
79 </div>
80 <div
81 role="img"
82 aria-label={heading}
83 className="relative"
84 onPointerOver={over}
85 // A tap's pointer leaves as the finger lifts: its hint stays until the next tap elsewhere.
86 onPointerLeave={(event) => event.pointerType === "mouse" && setHovered(null)}
87 >
88 <div aria-hidden="true" className="relative h-4" style={{ width: calendar.weeks.length * STEP - GAP }}>
89 {calendar.months.map((month) => (
90 <span key={`${month.week}:${month.label}`} className="absolute top-0" style={{ left: month.week * STEP }}>
91 {month.label}
92 </span>
93 ))}
94 </div>
95 <div className="flex" style={{ gap: GAP }}>
96 {calendar.weeks.map((week, column) => (
97 <div key={column} className="flex flex-col" style={{ gap: GAP }}>
98 {week.map((day, row) =>
99 day ? (
100 <span
101 key={row}
102 data-date={day.date}
103 data-count={day.count}
104 data-commits={day.commits}
105 className={`block rounded-[2px] ${SHADES[day.level]} ${hovered?.date === day.date ? "ring-1 ring-fg/60" : ""}`}
106 style={{ width: CELL, height: CELL }}
107 />
108 ) : (
109 <span key={row} className="block" style={{ width: CELL, height: CELL }} />
110 ),
111 )}
112 </div>
113 ))}
114 </div>
115 {/* The one hint, over whichever square the pointer is on. */}
116 <Tooltip open={hovered != null} onOpenChange={(open) => !open && setHovered(null)}>
117 <TooltipTrigger asChild>
118 <span
119 aria-hidden="true"
120 className="pointer-events-none absolute"
121 style={{ left: hovered?.left ?? 0, top: hovered?.top ?? 0, width: CELL, height: CELL }}
122 />
123 </TooltipTrigger>
124 <TooltipContent side="top">{hovered ? dayLabel(hovered.count, hovered.date, hovered.commits) : null}</TooltipContent>
125 </Tooltip>
126 </div>
127 </div>
128 </div>
129 <div aria-hidden="true" className="mt-2 flex items-center justify-end gap-1 text-[0.6875rem] text-faint">
130 <span className="mr-1">Less</span>
131 {([0, 1, 2, 3, 4] as Level[]).map((level) => (
132 <span key={level} className={`block rounded-[2px] ${SHADES[level]}`} style={{ width: CELL, height: CELL }} />
133 ))}
134 <span className="ml-1">More</span>
135 </div>
136 </section>
137 );
138}