|
6 | 6 | const CELL_STEP = CELL_SIZE + CELL_GAP; |
7 | 7 | const ROW_LABEL_WIDTH = 32; |
8 | 8 | const COL_LABEL_HEIGHT = 18; |
9 | | - const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; |
| 9 | + const DAY_LABELS = [ |
| 10 | + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", |
| 11 | + ]; |
10 | 12 |
|
11 | 13 | const LEVEL_COLORS_LIGHT = [ |
12 | 14 | "var(--bg-inset)", |
|
25 | 27 | ]; |
26 | 28 |
|
27 | 29 | function levelColor(level: number): string { |
28 | | - const isDark = document.documentElement.classList.contains( |
29 | | - "dark", |
30 | | - ); |
31 | | - const colors = isDark ? LEVEL_COLORS_DARK : LEVEL_COLORS_LIGHT; |
| 30 | + const isDark = |
| 31 | + document.documentElement.classList.contains("dark"); |
| 32 | + const colors = isDark |
| 33 | + ? LEVEL_COLORS_DARK |
| 34 | + : LEVEL_COLORS_LIGHT; |
32 | 35 | return colors[level] ?? colors[0]!; |
33 | 36 | } |
34 | 37 |
|
|
52 | 55 | const cells = analytics.hourOfWeek?.cells; |
53 | 56 | if (!cells || cells.length === 0) return null; |
54 | 57 |
|
55 | | - // Index cells by (day_of_week, hour) to avoid assuming |
56 | | - // array order. |
57 | 58 | const lookup = new Map<string, number>(); |
58 | 59 | let max = 0; |
59 | 60 | for (const c of cells) { |
60 | 61 | lookup.set(`${c.day_of_week}:${c.hour}`, c.messages); |
61 | 62 | if (c.messages > max) max = c.messages; |
62 | 63 | } |
63 | 64 |
|
64 | | - const rows: { day: string; hours: { hour: number; value: number; level: number }[] }[] = []; |
| 65 | + const rows: { |
| 66 | + day: string; |
| 67 | + dayIdx: number; |
| 68 | + hours: { |
| 69 | + hour: number; |
| 70 | + value: number; |
| 71 | + level: number; |
| 72 | + }[]; |
| 73 | + }[] = []; |
65 | 74 | for (let d = 0; d < 7; d++) { |
66 | | - const hours: { hour: number; value: number; level: number }[] = []; |
| 75 | + const hours: { |
| 76 | + hour: number; |
| 77 | + value: number; |
| 78 | + level: number; |
| 79 | + }[] = []; |
67 | 80 | for (let h = 0; h < 24; h++) { |
68 | 81 | const value = lookup.get(`${d}:${h}`) ?? 0; |
69 | 82 | hours.push({ |
|
72 | 85 | level: assignLevel(value, max), |
73 | 86 | }); |
74 | 87 | } |
75 | | - rows.push({ day: DAY_LABELS[d]!, hours }); |
| 88 | + rows.push({ day: DAY_LABELS[d]!, dayIdx: d, hours }); |
76 | 89 | } |
77 | 90 | return rows; |
78 | 91 | }); |
|
100 | 113 | function handleCellLeave() { |
101 | 114 | tooltip = null; |
102 | 115 | } |
| 116 | +
|
| 117 | + function handleCellClick(dow: number, hour: number) { |
| 118 | + analytics.selectHourOfWeek(dow, hour); |
| 119 | + } |
| 120 | +
|
| 121 | + function handleDayClick(dow: number) { |
| 122 | + analytics.selectHourOfWeek(dow, null); |
| 123 | + } |
| 124 | +
|
| 125 | + function handleHourClick(hour: number) { |
| 126 | + analytics.selectHourOfWeek(null, hour); |
| 127 | + } |
| 128 | +
|
| 129 | + function clearFilter() { |
| 130 | + analytics.selectHourOfWeek(null, null); |
| 131 | + } |
| 132 | +
|
| 133 | + function isDimmed(dow: number, hour: number): boolean { |
| 134 | + const sd = analytics.selectedDow; |
| 135 | + const sh = analytics.selectedHour; |
| 136 | + if (sd === null && sh === null) return false; |
| 137 | + if (sd !== null && sh !== null) { |
| 138 | + return dow !== sd || hour !== sh; |
| 139 | + } |
| 140 | + if (sd !== null) return dow !== sd; |
| 141 | + return hour !== sh; |
| 142 | + } |
| 143 | +
|
| 144 | + const hasFilter = $derived( |
| 145 | + analytics.selectedDow !== null || |
| 146 | + analytics.selectedHour !== null, |
| 147 | + ); |
| 148 | +
|
| 149 | + const filterLabel = $derived.by(() => { |
| 150 | + const sd = analytics.selectedDow; |
| 151 | + const sh = analytics.selectedHour; |
| 152 | + if (sd !== null && sh !== null) { |
| 153 | + return `${DAY_LABELS[sd]} ${sh.toString().padStart(2, "0")}:00`; |
| 154 | + } |
| 155 | + if (sd !== null) return DAY_LABELS[sd]!; |
| 156 | + if (sh !== null) { |
| 157 | + return `${sh.toString().padStart(2, "0")}:00`; |
| 158 | + } |
| 159 | + return ""; |
| 160 | + }); |
| 161 | +
|
| 162 | + function shortTz(tz: string): string { |
| 163 | + const slash = tz.lastIndexOf("/"); |
| 164 | + return slash >= 0 |
| 165 | + ? tz.slice(slash + 1).replace(/_/g, " ") |
| 166 | + : tz; |
| 167 | + } |
103 | 168 | </script> |
104 | 169 |
|
105 | 170 | <div class="how-container"> |
106 | | - <h3 class="chart-title">Activity by Day and Hour</h3> |
| 171 | + <div class="chart-header"> |
| 172 | + <h3 class="chart-title"> |
| 173 | + Activity by Day and Hour |
| 174 | + <span class="tz-label">{shortTz(analytics.timezone)}</span> |
| 175 | + </h3> |
| 176 | + {#if hasFilter} |
| 177 | + <span class="filter-badge"> |
| 178 | + {filterLabel} |
| 179 | + <button |
| 180 | + class="clear-btn" |
| 181 | + onclick={clearFilter} |
| 182 | + aria-label="Clear time filter" |
| 183 | + >×</button> |
| 184 | + </span> |
| 185 | + {/if} |
| 186 | + </div> |
107 | 187 |
|
108 | 188 | {#if analytics.loading.hourOfWeek} |
109 | 189 | <div class="loading">Loading...</div> |
|
129 | 209 | x={h * CELL_STEP + ROW_LABEL_WIDTH + CELL_SIZE / 2} |
130 | 210 | y={COL_LABEL_HEIGHT - 4} |
131 | 211 | class="hour-label" |
| 212 | + class:active-label={analytics.selectedHour === h} |
132 | 213 | text-anchor="middle" |
| 214 | + role="button" |
| 215 | + tabindex="-1" |
| 216 | + onclick={() => handleHourClick(h)} |
133 | 217 | > |
134 | 218 | {h} |
135 | 219 | </text> |
|
140 | 224 | x={ROW_LABEL_WIDTH - 4} |
141 | 225 | y={rowIdx * CELL_STEP + COL_LABEL_HEIGHT + CELL_SIZE - 2} |
142 | 226 | class="day-label" |
| 227 | + class:active-label={analytics.selectedDow === row.dayIdx} |
143 | 228 | text-anchor="end" |
| 229 | + role="button" |
| 230 | + tabindex="-1" |
| 231 | + onclick={() => handleDayClick(row.dayIdx)} |
144 | 232 | > |
145 | 233 | {row.day} |
146 | 234 | </text> |
|
154 | 242 | rx="2" |
155 | 243 | fill={levelColor(cell.level)} |
156 | 244 | class="how-cell" |
157 | | - role="img" |
| 245 | + class:dimmed={isDimmed(row.dayIdx, cell.hour)} |
| 246 | + role="button" |
| 247 | + tabindex="-1" |
158 | 248 | onmouseenter={(e) => |
159 | 249 | handleCellHover(e, row.day, cell.hour, cell.value)} |
160 | 250 | onmouseleave={handleCellLeave} |
| 251 | + onclick={() => |
| 252 | + handleCellClick(row.dayIdx, cell.hour)} |
161 | 253 | /> |
162 | 254 | {/each} |
163 | 255 | {/each} |
|
183 | 275 | flex: 1; |
184 | 276 | } |
185 | 277 |
|
| 278 | + .chart-header { |
| 279 | + display: flex; |
| 280 | + align-items: center; |
| 281 | + gap: 8px; |
| 282 | + margin-bottom: 8px; |
| 283 | + } |
| 284 | +
|
186 | 285 | .chart-title { |
187 | 286 | font-size: 12px; |
188 | 287 | font-weight: 600; |
189 | 288 | color: var(--text-primary); |
190 | | - margin-bottom: 8px; |
| 289 | + } |
| 290 | +
|
| 291 | + .tz-label { |
| 292 | + font-weight: 400; |
| 293 | + color: var(--text-muted); |
| 294 | + font-size: 10px; |
| 295 | + margin-left: 4px; |
| 296 | + } |
| 297 | +
|
| 298 | + .filter-badge { |
| 299 | + display: inline-flex; |
| 300 | + align-items: center; |
| 301 | + gap: 4px; |
| 302 | + padding: 1px 6px; |
| 303 | + font-size: 10px; |
| 304 | + font-weight: 500; |
| 305 | + color: var(--accent-blue); |
| 306 | + background: var(--user-bg); |
| 307 | + border-radius: var(--radius-sm); |
| 308 | + } |
| 309 | +
|
| 310 | + .clear-btn { |
| 311 | + font-size: 12px; |
| 312 | + line-height: 1; |
| 313 | + color: var(--text-muted); |
| 314 | + cursor: pointer; |
| 315 | + padding: 0 2px; |
| 316 | + } |
| 317 | +
|
| 318 | + .clear-btn:hover { |
| 319 | + color: var(--text-primary); |
191 | 320 | } |
192 | 321 |
|
193 | 322 | .how-scroll { |
|
204 | 333 | font-size: 9px; |
205 | 334 | fill: var(--text-muted); |
206 | 335 | font-family: var(--font-sans); |
| 336 | + cursor: pointer; |
| 337 | + } |
| 338 | +
|
| 339 | + .hour-label:hover, |
| 340 | + .day-label:hover { |
| 341 | + fill: var(--text-primary); |
| 342 | + } |
| 343 | +
|
| 344 | + .active-label { |
| 345 | + fill: var(--accent-blue); |
| 346 | + font-weight: 600; |
207 | 347 | } |
208 | 348 |
|
209 | 349 | .how-cell { |
210 | | - cursor: default; |
| 350 | + cursor: pointer; |
| 351 | + transition: opacity 0.15s; |
211 | 352 | } |
212 | 353 |
|
213 | 354 | .how-cell:hover { |
|
216 | 357 | stroke-width: 1; |
217 | 358 | } |
218 | 359 |
|
| 360 | + .how-cell.dimmed { |
| 361 | + opacity: 0.2; |
| 362 | + } |
| 363 | +
|
219 | 364 | .tooltip { |
220 | 365 | position: fixed; |
221 | 366 | transform: translateX(-50%) translateY(-100%); |
|
0 commit comments