Skip to content
119 linesCodeBlameRaw
1/**
2 * The contribution calendar on a profile, laid out: the last year as
3 * columns of weeks (Sunday at the top), each day shaded by how much was
4 * done that day, with the months named above the week each one starts in.
5 * Pure, so the page and its tests agree; every date is UTC, as the work
6 * service counts them (services/work/src/contributions.rs).
7 */
8
9/** How many weeks the calendar shows: enough for a whole year and the current week. */
10export const CALENDAR_WEEKS = 53;
11
12/** The shades a day can take: 0 is nothing, 4 the busiest. */
13export type Level = 0 | 1 | 2 | 3 | 4;
14
15/** One square: a day of the year, or null outside it (before it starts, after today). */
16export type CalendarDay = { date: string; count: number; commits: number; level: Level } | null;
17
18export type Calendar = {
19 /** `CALENDAR_WEEKS` columns of seven days, Sunday first. */
20 weeks: CalendarDay[][];
21 /** Each month's name, over the first column it starts in. */
22 months: { label: string; week: number }[];
23 /** The most on any one day. */
24 max: number;
25};
26
27const DAY_MS = 86_400_000;
28const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
29
30/** `YYYY-MM-DD` as milliseconds at its UTC midnight, or NaN. */
31function parse(date: string): number {
32 const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
33 return match ? Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) : Number.NaN;
34}
35
36function format(ms: number): string {
37 return new Date(ms).toISOString().slice(0, 10);
38}
39
40/** The shade for `count` on a calendar whose busiest day had `max`: quarters of it. */
41export function levelOf(count: number, max: number): Level {
42 if (count <= 0 || max <= 0) return 0;
43 return Math.min(4, Math.max(1, Math.ceil((count / max) * 4))) as Level;
44}
45
46/**
47 * The calendar ending with the week of `today` (`YYYY-MM-DD`). Days before
48 * `from`, when given, and after `today` are left blank; days with nothing
49 * in `days` are zero.
50 */
51export function buildCalendar(days: readonly { date: string; count: number; commits?: number }[], today: string, from?: string): Calendar {
52 const end = parse(today);
53 if (Number.isNaN(end)) return { weeks: [], months: [], max: 0 };
54 const first = from && !Number.isNaN(parse(from)) ? parse(from) : end - 364 * DAY_MS;
55 const counts = new Map<string, number>();
56 const commits = new Map<string, number>();
57 for (const day of days) {
58 counts.set(day.date, (counts.get(day.date) ?? 0) + day.count);
59 if (day.commits) commits.set(day.date, (commits.get(day.date) ?? 0) + day.commits);
60 }
61 let max = 0;
62 for (const [date, count] of counts) {
63 const at = parse(date);
64 if (at >= first && at <= end) max = Math.max(max, count);
65 }
66 // The Sunday that starts the first column.
67 const start = end - new Date(end).getUTCDay() * DAY_MS - (CALENDAR_WEEKS - 1) * 7 * DAY_MS;
68 const weeks: CalendarDay[][] = [];
69 const months: Calendar["months"] = [];
70 for (let week = 0; week < CALENDAR_WEEKS; week++) {
71 const column: CalendarDay[] = [];
72 for (let weekday = 0; weekday < 7; weekday++) {
73 const at = start + (week * 7 + weekday) * DAY_MS;
74 if (at < first || at > end) {
75 column.push(null);
76 continue;
77 }
78 const date = format(at);
79 const count = counts.get(date) ?? 0;
80 column.push({ date, count, commits: commits.get(date) ?? 0, level: levelOf(count, max) });
81 }
82 weeks.push(column);
83 // A month is named over the first column holding its first day, or over
84 // the first column at all when the year starts partway through it.
85 const named = column.find((day) => day?.date.endsWith("-01")) ?? (months.length === 0 ? column.find((day) => day != null) : null);
86 if (named) months.push({ label: MONTHS[Number(named.date.slice(5, 7)) - 1]!, week });
87 }
88 // A label squeezed in before the next one (too few columns to fit) goes.
89 if (months.length > 1 && months[1]!.week - months[0]!.week < 3) months.shift();
90 return { weeks, months, max };
91}
92
93/**
94 * What a day's hint says: "3 contributions on Oct 4, 2026", and how many
95 * of them were commits when some were: "5 contributions on Oct 4, 2026,
96 * 3 of them commits", or "3 commits on Oct 4, 2026" when all were.
97 */
98export function dayLabel(count: number, date: string, commits = 0): string {
99 const at = parse(date);
100 const when = Number.isNaN(at)
101 ? date
102 : new Date(at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
103 if (count === 0) return `No contributions on ${when}`;
104 if (commits > 0 && commits >= count) return `${count.toLocaleString("en-US")} ${count === 1 ? "commit" : "commits"} on ${when}`;
105 const all = `${count.toLocaleString("en-US")} ${count === 1 ? "contribution" : "contributions"} on ${when}`;
106 if (commits <= 0) return all;
107 return `${all}, ${commits.toLocaleString("en-US")} of them ${commits === 1 ? "a commit" : "commits"}`;
108}
109
110/** The heading: "1,204 contributions in the last year". */
111export function totalLabel(total: number): string {
112 return `${total.toLocaleString("en-US")} ${total === 1 ? "contribution" : "contributions"} in the last year`;
113}
114
115/** The day `from` (`YYYY-MM-DD`) plus 364: the last day of a year counted from it. */
116export function lastDay(from: string): string | null {
117 const at = parse(from);
118 return Number.isNaN(at) ? null : format(at + 364 * DAY_MS);
119}