File size: 4,738 Bytes
6c3af4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
/**
 * 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,
  };
}