export interface ActivityTs {
createdAt: Date | string | number;
}
export interface HeatmapDay {
date: string;
count: number;
level: 0 | 1 | 2 | 3 | 4;
dow: number;
}
export interface HeatmapWeek {
days: Array<HeatmapDay | null>;
}
export interface Heatmap {
weeks: HeatmapWeek[];
totalContributions: number;
maxDayCount: number;
longestStreak: number;
currentStreak: number;
startDate: string;
endDate: string;
}
export function startOfUtcDay(d: Date): Date {
const x = new Date(d);
x.setUTCHours(0, 0, 0, 0);
return x;
}
export function formatDateKey(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function daysBetween(a: Date, b: Date): number {
const ms = startOfUtcDay(b).getTime() - startOfUtcDay(a).getTime();
return Math.round(ms / 86_400_000);
}
export function levelFor(count: number, max: number): 0 | 1 | 2 | 3 | 4 {
if (count <= 0) return 0;
if (max <= 0) return 0;
const q = count / max;
if (q > 0.75) return 4;
if (q > 0.5) return 3;
if (q > 0.25) return 2;
return 1;
}
export function buildHeatmap(
activities: ActivityTs[],
windowDays: number = 365,
today: Date = new Date()
): Heatmap {
const endDay = startOfUtcDay(today);
const startDay = new Date(endDay);
startDay.setUTCDate(startDay.getUTCDate() - (windowDays - 1));
const byDay = new Map<string, number>();
for (const a of activities) {
const d = new Date(a.createdAt);
if (isNaN(d.getTime())) continue;
const k = startOfUtcDay(d).getTime();
if (k < startDay.getTime() || k > endDay.getTime()) continue;
const key = formatDateKey(new Date(k));
byDay.set(key, (byDay.get(key) || 0) + 1);
}
const firstDow = startDay.getUTCDay();
const gridStart = new Date(startDay);
gridStart.setUTCDate(gridStart.getUTCDate() - firstDow);
const endDow = endDay.getUTCDay();
const gridEnd = new Date(endDay);
gridEnd.setUTCDate(gridEnd.getUTCDate() + (6 - endDow));
let maxDayCount = 0;
for (const v of byDay.values()) if (v > maxDayCount) maxDayCount = v;
const weeks: HeatmapWeek[] = [];
let cursor = new Date(gridStart);
let totalContributions = 0;
let longestStreak = 0;
let currentStreakAtEnd = 0;
let runningStreak = 0;
while (cursor.getTime() <= gridEnd.getTime()) {
const days: Array<HeatmapDay | null> = [];
for (let i = 0; i < 7; i++) {
const isBeforeStart = cursor.getTime() < startDay.getTime();
const isAfterEnd = cursor.getTime() > endDay.getTime();
if (isBeforeStart || isAfterEnd) {
days.push(null);
} else {
const key = formatDateKey(cursor);
const count = byDay.get(key) || 0;
totalContributions += count;
if (count > 0) {
runningStreak += 1;
if (runningStreak > longestStreak) longestStreak = runningStreak;
} else {
runningStreak = 0;
}
if (cursor.getTime() === endDay.getTime()) {
currentStreakAtEnd = runningStreak;
}
days.push({
date: key,
count,
level: levelFor(count, maxDayCount),
dow: cursor.getUTCDay(),
});
}
cursor = new Date(cursor);
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
weeks.push({ days });
}
return {
weeks,
totalContributions,
maxDayCount,
longestStreak,
currentStreak: currentStreakAtEnd,
startDate: formatDateKey(startDay),
endDate: formatDateKey(endDay),
};
}
export const __internal = { formatDateKey, startOfUtcDay, daysBetween };
|