Skip to content
136 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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