|
| 1 | +/** |
| 2 | + * Calendar Heatmap Component - GitHub-style contribution grid. |
| 3 | + * |
| 4 | + * Renders an SVG calendar heatmap for the last 365 days. |
| 5 | + * Built via DOM API (createElementNS) for CSP compatibility. |
| 6 | + * |
| 7 | + * @license Unlicense <http://unlicense.org/> |
| 8 | + * @since 3.0.0 |
| 9 | + */ |
| 10 | + |
| 11 | +import Alpine from 'alpinejs'; |
| 12 | + |
| 13 | +const SVG_NS = 'http://www.w3.org/2000/svg'; |
| 14 | + |
| 15 | +/** Cell size and gap for the grid. */ |
| 16 | +const CELL_SIZE = 11; |
| 17 | +const CELL_GAP = 2; |
| 18 | +const CELL_STEP = CELL_SIZE + CELL_GAP; |
| 19 | + |
| 20 | +/** Color scale: 0 = empty, 1-4 = increasing intensity. */ |
| 21 | +const COLORS = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39']; |
| 22 | + |
| 23 | +/** Short month labels for the top axis. */ |
| 24 | +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', |
| 25 | + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; |
| 26 | + |
| 27 | +/** Short day-of-week labels for the left axis. */ |
| 28 | +const DAY_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', '']; |
| 29 | + |
| 30 | +interface CalendarDay { |
| 31 | + date: string; |
| 32 | + total: number; |
| 33 | + created: number; |
| 34 | + reviewed: number; |
| 35 | + read: number; |
| 36 | + col: number; |
| 37 | + row: number; |
| 38 | +} |
| 39 | + |
| 40 | +interface DayActivity { |
| 41 | + total: number; |
| 42 | + created: number; |
| 43 | + reviewed: number; |
| 44 | + read: number; |
| 45 | +} |
| 46 | + |
| 47 | +interface HeatmapData { |
| 48 | + [date: string]: DayActivity; |
| 49 | +} |
| 50 | + |
| 51 | +interface CalendarHeatmapState { |
| 52 | + loading: boolean; |
| 53 | + error: string; |
| 54 | + init(this: CalendarHeatmapState & { $refs: Record<string, HTMLElement> }): void; |
| 55 | + buildCalendar(container: HTMLElement, data: HeatmapData): void; |
| 56 | +} |
| 57 | + |
| 58 | +/** |
| 59 | + * Map a raw activity count to a color index (0-4). |
| 60 | + */ |
| 61 | +function colorLevel(value: number, max: number): number { |
| 62 | + if (value === 0) return 0; |
| 63 | + if (max <= 0) return 1; |
| 64 | + const ratio = value / max; |
| 65 | + if (ratio <= 0.25) return 1; |
| 66 | + if (ratio <= 0.50) return 2; |
| 67 | + if (ratio <= 0.75) return 3; |
| 68 | + return 4; |
| 69 | +} |
| 70 | + |
| 71 | +/** |
| 72 | + * Format a date string (YYYY-MM-DD) for tooltip display. |
| 73 | + */ |
| 74 | +function formatDate(dateStr: string): string { |
| 75 | + const d = new Date(dateStr + 'T00:00:00'); |
| 76 | + return d.toLocaleDateString(undefined, { |
| 77 | + weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' |
| 78 | + }); |
| 79 | +} |
| 80 | + |
| 81 | +/** |
| 82 | + * Build a tooltip string showing the activity breakdown for a day. |
| 83 | + */ |
| 84 | +function formatTooltip(day: CalendarDay): string { |
| 85 | + const date = formatDate(day.date); |
| 86 | + if (day.total === 0) return `No activity on ${date}`; |
| 87 | + const parts: string[] = []; |
| 88 | + if (day.created > 0) parts.push(`${day.created} created`); |
| 89 | + if (day.reviewed > 0) parts.push(`${day.reviewed} reviewed`); |
| 90 | + if (day.read > 0) parts.push(`${day.read} read`); |
| 91 | + return `${parts.join(', ')} — ${date}`; |
| 92 | +} |
| 93 | + |
| 94 | +/** |
| 95 | + * Build a grid of CalendarDay objects for the last 365 days. |
| 96 | + */ |
| 97 | +function buildDayGrid(data: HeatmapData): { days: CalendarDay[]; weeks: number } { |
| 98 | + const today = new Date(); |
| 99 | + today.setHours(0, 0, 0, 0); |
| 100 | + const days: CalendarDay[] = []; |
| 101 | + |
| 102 | + // Go back 364 days so we cover ~52 full weeks + today |
| 103 | + const start = new Date(today); |
| 104 | + start.setDate(start.getDate() - 364); |
| 105 | + // Align start to the previous Sunday |
| 106 | + start.setDate(start.getDate() - start.getDay()); |
| 107 | + |
| 108 | + let col = 0; |
| 109 | + const cursor = new Date(start); |
| 110 | + while (cursor <= today) { |
| 111 | + const dateStr = cursor.toISOString().slice(0, 10); |
| 112 | + const row = cursor.getDay(); // 0=Sun, 6=Sat |
| 113 | + const entry = data[dateStr]; |
| 114 | + days.push({ |
| 115 | + date: dateStr, |
| 116 | + total: entry?.total ?? 0, |
| 117 | + created: entry?.created ?? 0, |
| 118 | + reviewed: entry?.reviewed ?? 0, |
| 119 | + read: entry?.read ?? 0, |
| 120 | + col, |
| 121 | + row, |
| 122 | + }); |
| 123 | + cursor.setDate(cursor.getDate() + 1); |
| 124 | + if (cursor.getDay() === 0) col++; |
| 125 | + } |
| 126 | + return { days, weeks: col + 1 }; |
| 127 | +} |
| 128 | + |
| 129 | +/** |
| 130 | + * Alpine.js component for the calendar heatmap. |
| 131 | + */ |
| 132 | +export function calendarHeatmap(): CalendarHeatmapState { |
| 133 | + return { |
| 134 | + loading: true, |
| 135 | + error: '', |
| 136 | + |
| 137 | + init() { |
| 138 | + const container = this.$refs.svgContainer; |
| 139 | + if (!container) return; |
| 140 | + |
| 141 | + fetch('/api/v1/activity/calendar') |
| 142 | + .then(r => r.json()) |
| 143 | + .then((data: HeatmapData) => { |
| 144 | + this.loading = false; |
| 145 | + this.buildCalendar(container, data); |
| 146 | + }) |
| 147 | + .catch(() => { |
| 148 | + this.loading = false; |
| 149 | + this.error = 'Failed to load activity data'; |
| 150 | + }); |
| 151 | + }, |
| 152 | + |
| 153 | + buildCalendar(container: HTMLElement, data: HeatmapData) { |
| 154 | + const { days, weeks } = buildDayGrid(data); |
| 155 | + const maxVal = Math.max(...days.map(d => d.total), 1); |
| 156 | + |
| 157 | + const leftPad = 30; // space for day-of-week labels |
| 158 | + const topPad = 16; // space for month labels |
| 159 | + const svgWidth = leftPad + weeks * CELL_STEP; |
| 160 | + const svgHeight = topPad + 7 * CELL_STEP; |
| 161 | + |
| 162 | + const svg = document.createElementNS(SVG_NS, 'svg'); |
| 163 | + svg.setAttribute('width', String(svgWidth)); |
| 164 | + svg.setAttribute('height', String(svgHeight)); |
| 165 | + svg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`); |
| 166 | + svg.style.display = 'block'; |
| 167 | + |
| 168 | + // Day-of-week labels |
| 169 | + for (let row = 0; row < 7; row++) { |
| 170 | + if (DAY_LABELS[row]) { |
| 171 | + const text = document.createElementNS(SVG_NS, 'text'); |
| 172 | + text.setAttribute('x', '0'); |
| 173 | + text.setAttribute('y', String(topPad + row * CELL_STEP + CELL_SIZE)); |
| 174 | + text.setAttribute('font-size', '10'); |
| 175 | + text.setAttribute('fill', '#767676'); |
| 176 | + text.textContent = DAY_LABELS[row]; |
| 177 | + svg.appendChild(text); |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + // Month labels — place at first Sunday of each month |
| 182 | + const monthsPlaced = new Set<number>(); |
| 183 | + for (const day of days) { |
| 184 | + const d = new Date(day.date + 'T00:00:00'); |
| 185 | + const m = d.getMonth(); |
| 186 | + if (day.row === 0 && !monthsPlaced.has(m)) { |
| 187 | + monthsPlaced.add(m); |
| 188 | + const text = document.createElementNS(SVG_NS, 'text'); |
| 189 | + text.setAttribute('x', String(leftPad + day.col * CELL_STEP)); |
| 190 | + text.setAttribute('y', '10'); |
| 191 | + text.setAttribute('font-size', '10'); |
| 192 | + text.setAttribute('fill', '#767676'); |
| 193 | + text.textContent = MONTHS[m]; |
| 194 | + svg.appendChild(text); |
| 195 | + } |
| 196 | + } |
| 197 | + |
| 198 | + // Day cells |
| 199 | + const tooltip = document.createElement('div'); |
| 200 | + tooltip.style.cssText = |
| 201 | + 'position:fixed;padding:4px 8px;background:#24292f;color:#fff;' + |
| 202 | + 'border-radius:4px;font-size:11px;pointer-events:none;display:none;z-index:100;'; |
| 203 | + document.body.appendChild(tooltip); |
| 204 | + |
| 205 | + for (const day of days) { |
| 206 | + const rect = document.createElementNS(SVG_NS, 'rect'); |
| 207 | + const x = leftPad + day.col * CELL_STEP; |
| 208 | + const y = topPad + day.row * CELL_STEP; |
| 209 | + rect.setAttribute('x', String(x)); |
| 210 | + rect.setAttribute('y', String(y)); |
| 211 | + rect.setAttribute('width', String(CELL_SIZE)); |
| 212 | + rect.setAttribute('height', String(CELL_SIZE)); |
| 213 | + rect.setAttribute('rx', '2'); |
| 214 | + rect.setAttribute('fill', COLORS[colorLevel(day.total, maxVal)]); |
| 215 | + |
| 216 | + const label = formatTooltip(day); |
| 217 | + rect.addEventListener('mouseenter', (e: MouseEvent) => { |
| 218 | + tooltip.textContent = label; |
| 219 | + tooltip.style.display = 'block'; |
| 220 | + tooltip.style.left = e.clientX + 8 + 'px'; |
| 221 | + tooltip.style.top = e.clientY - 28 + 'px'; |
| 222 | + }); |
| 223 | + rect.addEventListener('mouseleave', () => { |
| 224 | + tooltip.style.display = 'none'; |
| 225 | + }); |
| 226 | + |
| 227 | + svg.appendChild(rect); |
| 228 | + } |
| 229 | + |
| 230 | + container.appendChild(svg); |
| 231 | + }, |
| 232 | + }; |
| 233 | +} |
| 234 | + |
| 235 | +Alpine.data('calendarHeatmap', calendarHeatmap); |
0 commit comments