Skip to content

Commit 075e273

Browse files
wesmclaude
andcommitted
feat: add hour-of-week heatmap filtering and timezone display
Click heatmap cells to filter all analytics panels by day+hour combo, click day labels to filter by day-of-week, or click hour labels to filter by hour. The heatmap itself always shows full data so the selection context is visible. Timezone is displayed in the chart header. Backend adds DayOfWeek/Hour pointer fields to AnalyticsFilter with a filteredSessionIDs pre-query approach that finds matching sessions by scanning message timestamps, then post-filters session-level queries using the ID set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4b3a94d commit 075e273

File tree

6 files changed

+553
-41
lines changed

6 files changed

+553
-41
lines changed

frontend/src/lib/api/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,8 @@ export interface AnalyticsParams {
328328
timezone?: string;
329329
machine?: string;
330330
project?: string;
331+
dow?: number;
332+
hour?: number;
331333
}
332334

333335
export function getAnalyticsSummary(

frontend/src/lib/components/analytics/HourOfWeekHeatmap.svelte

Lines changed: 159 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
const CELL_STEP = CELL_SIZE + CELL_GAP;
77
const ROW_LABEL_WIDTH = 32;
88
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+
];
1012
1113
const LEVEL_COLORS_LIGHT = [
1214
"var(--bg-inset)",
@@ -25,10 +27,11 @@
2527
];
2628
2729
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;
3235
return colors[level] ?? colors[0]!;
3336
}
3437
@@ -52,18 +55,28 @@
5255
const cells = analytics.hourOfWeek?.cells;
5356
if (!cells || cells.length === 0) return null;
5457
55-
// Index cells by (day_of_week, hour) to avoid assuming
56-
// array order.
5758
const lookup = new Map<string, number>();
5859
let max = 0;
5960
for (const c of cells) {
6061
lookup.set(`${c.day_of_week}:${c.hour}`, c.messages);
6162
if (c.messages > max) max = c.messages;
6263
}
6364
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+
}[] = [];
6574
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+
}[] = [];
6780
for (let h = 0; h < 24; h++) {
6881
const value = lookup.get(`${d}:${h}`) ?? 0;
6982
hours.push({
@@ -72,7 +85,7 @@
7285
level: assignLevel(value, max),
7386
});
7487
}
75-
rows.push({ day: DAY_LABELS[d]!, hours });
88+
rows.push({ day: DAY_LABELS[d]!, dayIdx: d, hours });
7689
}
7790
return rows;
7891
});
@@ -100,10 +113,77 @@
100113
function handleCellLeave() {
101114
tooltip = null;
102115
}
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+
}
103168
</script>
104169

105170
<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+
>&times;</button>
184+
</span>
185+
{/if}
186+
</div>
107187

108188
{#if analytics.loading.hourOfWeek}
109189
<div class="loading">Loading...</div>
@@ -129,7 +209,11 @@
129209
x={h * CELL_STEP + ROW_LABEL_WIDTH + CELL_SIZE / 2}
130210
y={COL_LABEL_HEIGHT - 4}
131211
class="hour-label"
212+
class:active-label={analytics.selectedHour === h}
132213
text-anchor="middle"
214+
role="button"
215+
tabindex="-1"
216+
onclick={() => handleHourClick(h)}
133217
>
134218
{h}
135219
</text>
@@ -140,7 +224,11 @@
140224
x={ROW_LABEL_WIDTH - 4}
141225
y={rowIdx * CELL_STEP + COL_LABEL_HEIGHT + CELL_SIZE - 2}
142226
class="day-label"
227+
class:active-label={analytics.selectedDow === row.dayIdx}
143228
text-anchor="end"
229+
role="button"
230+
tabindex="-1"
231+
onclick={() => handleDayClick(row.dayIdx)}
144232
>
145233
{row.day}
146234
</text>
@@ -154,10 +242,14 @@
154242
rx="2"
155243
fill={levelColor(cell.level)}
156244
class="how-cell"
157-
role="img"
245+
class:dimmed={isDimmed(row.dayIdx, cell.hour)}
246+
role="button"
247+
tabindex="-1"
158248
onmouseenter={(e) =>
159249
handleCellHover(e, row.day, cell.hour, cell.value)}
160250
onmouseleave={handleCellLeave}
251+
onclick={() =>
252+
handleCellClick(row.dayIdx, cell.hour)}
161253
/>
162254
{/each}
163255
{/each}
@@ -183,11 +275,48 @@
183275
flex: 1;
184276
}
185277
278+
.chart-header {
279+
display: flex;
280+
align-items: center;
281+
gap: 8px;
282+
margin-bottom: 8px;
283+
}
284+
186285
.chart-title {
187286
font-size: 12px;
188287
font-weight: 600;
189288
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);
191320
}
192321
193322
.how-scroll {
@@ -204,10 +333,22 @@
204333
font-size: 9px;
205334
fill: var(--text-muted);
206335
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;
207347
}
208348
209349
.how-cell {
210-
cursor: default;
350+
cursor: pointer;
351+
transition: opacity 0.15s;
211352
}
212353
213354
.how-cell:hover {
@@ -216,6 +357,10 @@
216357
stroke-width: 1;
217358
}
218359
360+
.how-cell.dimmed {
361+
opacity: 0.2;
362+
}
363+
219364
.tooltip {
220365
position: fixed;
221366
transform: translateX(-50%) translateY(-100%);

0 commit comments

Comments
 (0)