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