| 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. */ |
| 10 | export const CALENDAR_WEEKS = 53; |
| 11 | |
| 12 | /** The shades a day can take: 0 is nothing, 4 the busiest. */ |
| 13 | export 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). */ |
| 16 | export type CalendarDay = { date: string; count: number; commits: number; level: Level } | null; |
| 17 | |
| 18 | export 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 | |
| 27 | const DAY_MS = 86_400_000; |
| 28 | const 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. */ |
| 31 | function 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 | |
| 36 | function 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. */ |
| 41 | export 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 | */ |
| 51 | export 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 | */ |
| 98 | export 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". */ |
| 111 | export 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. */ |
| 116 | export function lastDay(from: string): string | null { |
| 117 | const at = parse(from); |
| 118 | return Number.isNaN(at) ? null : format(at + 364 * DAY_MS); |
| 119 | } |