Spaces:
Running
Running
| /** | |
| * Builds a GitHub-style contribution heatmap from search timestamps. | |
| * | |
| * Days are bucketed by the viewer's local calendar day (matching | |
| * `formatRelativeTime` and the hour math in SearchStats). The grid is a | |
| * rolling window of whole weeks ending on the week that contains "today", | |
| * with columns laid out Sunday-first like GitHub's calendar. | |
| */ | |
| const MIN_WEEKS = 12; | |
| const MAX_WEEKS = 26; | |
| const MONTH_NAMES = [ | |
| "Jan", | |
| "Feb", | |
| "Mar", | |
| "Apr", | |
| "May", | |
| "Jun", | |
| "Jul", | |
| "Aug", | |
| "Sep", | |
| "Oct", | |
| "Nov", | |
| "Dec", | |
| ]; | |
| interface DayCell { | |
| /** Local calendar day as YYYY-MM-DD. */ | |
| date: string; | |
| count: number; | |
| /** Filler day after "today" that only exists to square off the last week. */ | |
| future: boolean; | |
| } | |
| interface ActivityStats { | |
| total: number; | |
| daysActive: number; | |
| busiestCount: number; | |
| /** Consecutive days with activity ending today (0 if today is empty). */ | |
| currentStreak: number; | |
| longestStreak: number; | |
| } | |
| export interface ActivityData { | |
| /** Column-major: each column is a Sunday..Saturday array of 7 cells. */ | |
| columns: DayCell[][]; | |
| /** Per-column month label; empty string unless a new month starts there. */ | |
| monthLabels: string[]; | |
| stats: ActivityStats; | |
| /** Highest single-day count in the window; used to scale color intensity. */ | |
| maxCount: number; | |
| weeks: number; | |
| } | |
| function startOfDay(ts: number): Date { | |
| const d = new Date(ts); | |
| d.setHours(0, 0, 0, 0); | |
| return d; | |
| } | |
| function localKey(d: Date): string { | |
| const month = `${d.getMonth() + 1}`.padStart(2, "0"); | |
| const day = `${d.getDate()}`.padStart(2, "0"); | |
| return `${d.getFullYear()}-${month}-${day}`; | |
| } | |
| /** | |
| * Maps a day's count to an intensity level 0-4 relative to the busiest day. | |
| * @param count - Searches on the day | |
| * @param maxCount - Busiest single-day count in the window | |
| * @returns 0 (empty) through 4 (most intense) | |
| */ | |
| export function intensityLevel(count: number, maxCount: number): number { | |
| if (count <= 0 || maxCount <= 0) return 0; | |
| return Math.min(4, Math.ceil((count / maxCount) * 4)); | |
| } | |
| /** | |
| * Builds the heatmap grid and summary stats from raw search timestamps. | |
| * @param timestamps - Search timestamps in milliseconds (any order) | |
| * @param now - Reference "today" timestamp (defaults to current time) | |
| * @returns Grid columns, month labels, and streak/activity stats | |
| */ | |
| export function buildActivity( | |
| timestamps: number[], | |
| now: number = Date.now(), | |
| ): ActivityData { | |
| const counts = new Map<string, number>(); | |
| for (const ts of timestamps) { | |
| const key = localKey(startOfDay(ts)); | |
| counts.set(key, (counts.get(key) ?? 0) + 1); | |
| } | |
| const today = startOfDay(now); | |
| const lastSunday = new Date(today); | |
| lastSunday.setDate(today.getDate() - today.getDay()); | |
| const oldest = timestamps.length | |
| ? startOfDay(Math.min(...timestamps)) | |
| : today; | |
| const oldestSunday = new Date(oldest); | |
| oldestSunday.setDate(oldest.getDate() - oldest.getDay()); | |
| let weeksSpan = 1; | |
| const cursor = new Date(oldestSunday); | |
| while (cursor < lastSunday) { | |
| cursor.setDate(cursor.getDate() + 7); | |
| weeksSpan += 1; | |
| } | |
| const weeks = Math.min(MAX_WEEKS, Math.max(MIN_WEEKS, weeksSpan)); | |
| const gridStart = new Date(lastSunday); | |
| gridStart.setDate(lastSunday.getDate() - (weeks - 1) * 7); | |
| const todayTime = today.getTime(); | |
| const columns: DayCell[][] = []; | |
| const monthLabels: string[] = []; | |
| const series: number[] = []; | |
| let maxCount = 0; | |
| const day = new Date(gridStart); | |
| let previousMonth = -1; | |
| for (let w = 0; w < weeks; w++) { | |
| const column: DayCell[] = []; | |
| monthLabels.push(""); | |
| for (let dow = 0; dow < 7; dow++) { | |
| if (dow === 0) { | |
| monthLabels[w] = | |
| day.getMonth() === previousMonth ? "" : MONTH_NAMES[day.getMonth()]; | |
| previousMonth = day.getMonth(); | |
| } | |
| const key = localKey(day); | |
| const count = counts.get(key) ?? 0; | |
| const future = day.getTime() > todayTime; | |
| column.push({ date: key, count, future }); | |
| if (!future) { | |
| series.push(count); | |
| if (count > maxCount) maxCount = count; | |
| } | |
| day.setDate(day.getDate() + 1); | |
| } | |
| columns.push(column); | |
| } | |
| let total = 0; | |
| let daysActive = 0; | |
| let longestStreak = 0; | |
| let run = 0; | |
| for (const count of series) { | |
| total += count; | |
| if (count > 0) { | |
| daysActive += 1; | |
| run += 1; | |
| if (run > longestStreak) longestStreak = run; | |
| } else { | |
| run = 0; | |
| } | |
| } | |
| let currentStreak = 0; | |
| for (let i = series.length - 1; i >= 0 && series[i] > 0; i--) { | |
| currentStreak += 1; | |
| } | |
| return { | |
| columns, | |
| monthLabels, | |
| stats: { | |
| total, | |
| daysActive, | |
| busiestCount: maxCount, | |
| currentStreak, | |
| longestStreak, | |
| }, | |
| maxCount, | |
| weeks, | |
| }; | |
| } | |