Skip to content
108 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; 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 }[], 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 for (const day of days) counts.set(day.date, (counts.get(day.date) ?? 0) + day.count);
57 let max = 0;
58 for (const [date, count] of counts) {
59 const at = parse(date);
60 if (at >= first && at <= end) max = Math.max(max, count);
61 }
62 // The Sunday that starts the first column.
63 const start = end - new Date(end).getUTCDay() * DAY_MS - (CALENDAR_WEEKS - 1) * 7 * DAY_MS;
64 const weeks: CalendarDay[][] = [];
65 const months: Calendar["months"] = [];
66 for (let week = 0; week < CALENDAR_WEEKS; week++) {
67 const column: CalendarDay[] = [];
68 for (let weekday = 0; weekday < 7; weekday++) {
69 const at = start + (week * 7 + weekday) * DAY_MS;
70 if (at < first || at > end) {
71 column.push(null);
72 continue;
73 }
74 const date = format(at);
75 const count = counts.get(date) ?? 0;
76 column.push({ date, count, level: levelOf(count, max) });
77 }
78 weeks.push(column);
79 // A month is named over the first column holding its first day, or over
80 // the first column at all when the year starts partway through it.
81 const named = column.find((day) => day?.date.endsWith("-01")) ?? (months.length === 0 ? column.find((day) => day != null) : null);
82 if (named) months.push({ label: MONTHS[Number(named.date.slice(5, 7)) - 1]!, week });
83 }
84 // A label squeezed in before the next one (too few columns to fit) goes.
85 if (months.length > 1 && months[1]!.week - months[0]!.week < 3) months.shift();
86 return { weeks, months, max };
87}
88
89/** What a day's hint says: "3 contributions on Oct 4, 2026". */
90export function dayLabel(count: number, date: string): string {
91 const at = parse(date);
92 const when = Number.isNaN(at)
93 ? date
94 : new Date(at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
95 if (count === 0) return `No contributions on ${when}`;
96 return `${count.toLocaleString("en-US")} ${count === 1 ? "contribution" : "contributions"} on ${when}`;
97}
98
99/** The heading: "1,204 contributions in the last year". */
100export function totalLabel(total: number): string {
101 return `${total.toLocaleString("en-US")} ${total === 1 ? "contribution" : "contributions"} in the last year`;
102}
103
104/** The day `from` (`YYYY-MM-DD`) plus 364: the last day of a year counted from it. */
105export function lastDay(from: string): string | null {
106 const at = parse(from);
107 return Number.isNaN(at) ? null : format(at + 364 * DAY_MS);
108}