Skip to content

Commit 1cc32c6

Browse files
committed
feat(frontend): move Trends bucketing onto the chart, away from the dates
The Trends toolbar carried a day/week/month granularity bar right next to the new date picker, whose Calendar tab also offers day/week/month -- two controls with identical labels meaning different things (chart bucketing vs date span). Move the granularity and the normalize toggle out of the date toolbar and into the chart panel's header: a minimal "Group by" dropdown (bar-chart icon, opens Day/Week/Month) plus the normalize checkbox. The top row is now only the date scope, and how the chart is drawn lives where it acts -- matching how Analytics places its own timeline granularity on the chart.
1 parent 036d9ac commit 1cc32c6

2 files changed

Lines changed: 166 additions & 37 deletions

File tree

frontend/src/lib/components/trends/TrendsPage.svelte

Lines changed: 142 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { getBasePath } from "../../stores/router.svelte.js";
55
import { sync } from "../../stores/sync.svelte.js";
66
import type { TrendsGranularity } from "../../api/types.js";
7+
import { ChartColumnIcon, ChevronDownIcon } from "../../icons.js";
78
import RangePicker from "../shared/RangePicker.svelte";
89
import {
910
resolveRange,
@@ -30,6 +31,25 @@
3031
3132
let activeTerm: string | null = $state(null);
3233
34+
const GRANULARITIES: TrendsGranularity[] = ["day", "week", "month"];
35+
let groupByOpen = $state(false);
36+
let groupByEl: HTMLDivElement | undefined = $state();
37+
38+
function pickGranularity(g: TrendsGranularity) {
39+
groupByOpen = false;
40+
if (g !== trends.granularity) void setGranularity(g);
41+
}
42+
43+
function onGroupByDocClick(e: MouseEvent) {
44+
if (groupByEl && !groupByEl.contains(e.target as Node)) {
45+
groupByOpen = false;
46+
}
47+
}
48+
49+
function onGroupByKey(e: KeyboardEvent) {
50+
if (e.key === "Escape") groupByOpen = false;
51+
}
52+
3353
function colorFor(_term: string, index: number): string {
3454
return TREND_PALETTE[index % TREND_PALETTE.length]!;
3555
}
@@ -110,6 +130,12 @@
110130
applyQueryParams();
111131
writeUrl();
112132
trends.fetchTerms();
133+
document.addEventListener("click", onGroupByDocClick);
134+
document.addEventListener("keydown", onGroupByKey);
135+
return () => {
136+
document.removeEventListener("click", onGroupByDocClick);
137+
document.removeEventListener("keydown", onGroupByKey);
138+
};
113139
});
114140
</script>
115141

@@ -134,24 +160,6 @@
134160
{earliestSession}
135161
onSelect={applyRange}
136162
/>
137-
<div class="granularity" aria-label="Granularity">
138-
{#each ["day", "week", "month"] as value}
139-
<button
140-
class:active={trends.granularity === value}
141-
onclick={() => setGranularity(value as TrendsGranularity)}
142-
>
143-
{value}
144-
</button>
145-
{/each}
146-
</div>
147-
<label class="normalize-toggle">
148-
<input
149-
type="checkbox"
150-
bind:checked={trends.normalized}
151-
onchange={setNormalized}
152-
/>
153-
<span>Normalize by number of messages</span>
154-
</label>
155163
</div>
156164

157165
<div class="content-grid">
@@ -172,6 +180,48 @@
172180
</div>
173181

174182
<div class="chart-panel" aria-busy={trends.loading.terms}>
183+
<div class="chart-options">
184+
<div class="group-by" bind:this={groupByEl}>
185+
<button
186+
class="group-trigger"
187+
onclick={() => (groupByOpen = !groupByOpen)}
188+
aria-haspopup="menu"
189+
aria-expanded={groupByOpen}
190+
>
191+
<ChartColumnIcon size="13" strokeWidth="2" aria-hidden="true" />
192+
Group by <span class="gval">{trends.granularity}</span>
193+
<ChevronDownIcon
194+
class={groupByOpen ? "g-chev open" : "g-chev"}
195+
size="11"
196+
strokeWidth="2.2"
197+
aria-hidden="true"
198+
/>
199+
</button>
200+
{#if groupByOpen}
201+
<div class="group-menu" role="menu">
202+
{#each GRANULARITIES as g (g)}
203+
<button
204+
class="group-item"
205+
class:active={trends.granularity === g}
206+
role="menuitemradio"
207+
aria-checked={trends.granularity === g}
208+
onclick={() => pickGranularity(g)}
209+
>
210+
{g}
211+
</button>
212+
{/each}
213+
</div>
214+
{/if}
215+
</div>
216+
<label class="normalize-toggle">
217+
<input
218+
type="checkbox"
219+
bind:checked={trends.normalized}
220+
onchange={setNormalized}
221+
/>
222+
<span>Normalize by number of messages</span>
223+
</label>
224+
</div>
175225
<TrendsLineChart
176226
buckets={trends.response?.buckets ?? []}
177227
series={trends.response?.series ?? []}
@@ -259,8 +309,7 @@
259309
}
260310
261311
.head-actions,
262-
.toolbar,
263-
.granularity {
312+
.toolbar {
264313
display: flex;
265314
align-items: center;
266315
gap: 8px;
@@ -336,40 +385,96 @@
336385
font-size: 12px;
337386
}
338387
339-
.granularity {
340-
align-self: end;
341-
height: 32px;
342-
padding: 2px;
343-
border: 1px solid var(--border-default);
344-
border-radius: 7px;
345-
background: var(--bg-surface);
388+
.chart-options {
389+
display: flex;
390+
align-items: center;
391+
justify-content: flex-end;
392+
gap: 14px;
393+
padding: 2px 2px 10px;
394+
}
395+
396+
.group-by {
397+
position: relative;
346398
}
347399
348-
.granularity button {
400+
.group-trigger {
349401
height: 26px;
350-
min-width: 54px;
351-
padding: 0 10px;
402+
padding: 0 8px;
403+
display: inline-flex;
404+
align-items: center;
405+
gap: 6px;
352406
border: 0;
407+
border-radius: 6px;
353408
background: transparent;
354409
color: var(--text-muted);
410+
font-size: 12px;
411+
}
412+
413+
.group-trigger:hover:not(:disabled) {
414+
background: var(--bg-surface-hover);
415+
color: var(--text-secondary);
416+
}
417+
418+
.group-trigger .gval {
419+
color: var(--text-secondary);
420+
font-weight: 500;
355421
text-transform: capitalize;
422+
}
423+
424+
:global(.g-chev) {
425+
color: var(--text-muted);
426+
transition: transform 0.15s;
427+
}
428+
429+
:global(.g-chev.open) {
430+
transform: rotate(180deg);
431+
}
432+
433+
.group-menu {
434+
position: absolute;
435+
top: calc(100% + 4px);
436+
right: 0;
437+
z-index: 20;
438+
min-width: 124px;
439+
padding: 4px;
440+
background: var(--bg-surface);
441+
border: 1px solid var(--border-muted);
442+
border-radius: 7px;
443+
box-shadow: var(--shadow-md);
444+
}
445+
446+
.group-item {
447+
width: 100%;
448+
height: 28px;
449+
padding: 0 9px;
450+
display: flex;
451+
align-items: center;
452+
border: 0;
453+
border-radius: 5px;
454+
background: transparent;
455+
color: var(--text-secondary);
356456
font-size: 12px;
457+
text-align: left;
458+
text-transform: capitalize;
357459
}
358460
359-
.granularity button.active {
360-
background: var(--bg-hover);
361-
color: var(--text-primary);
461+
.group-item:hover:not(:disabled) {
462+
background: var(--bg-surface-hover);
463+
}
464+
465+
.group-item.active {
466+
color: var(--accent-blue);
467+
font-weight: 500;
362468
}
363469
364470
.normalize-toggle {
365-
align-self: end;
366-
height: 32px;
367471
display: flex;
368472
align-items: center;
369-
gap: 7px;
370-
color: var(--text-primary);
473+
gap: 6px;
474+
color: var(--text-muted);
371475
font-size: 12px;
372476
font-weight: 500;
477+
cursor: pointer;
373478
}
374479
375480
.normalize-toggle input {

frontend/src/lib/components/trends/TrendsPage.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,30 @@ describe("TrendsPage", () => {
113113
expect(window.location.search).toContain("from=2024-01-10");
114114
});
115115

116+
it("changes bucketing via the chart Group by menu", async () => {
117+
component = mount(TrendsPage, { target: document.body });
118+
await flushPromises();
119+
120+
const trigger = Array.from(
121+
document.querySelectorAll<HTMLButtonElement>("button"),
122+
).find((b) => b.textContent?.includes("Group by"));
123+
expect(trigger).not.toBeNull();
124+
trigger!.click();
125+
await tick();
126+
127+
const monthItem = Array.from(
128+
document.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
129+
).find((b) => b.textContent?.trim() === "month");
130+
expect(monthItem).not.toBeNull();
131+
monthItem!.click();
132+
await flushPromises();
133+
134+
expect(mocks.getApiV1TrendsTerms).toHaveBeenLastCalledWith(
135+
expect.objectContaining({ granularity: "month" }),
136+
);
137+
expect(window.location.search).toContain("granularity=month");
138+
});
139+
116140
it("shows the terms entry format hint", async () => {
117141
component = mount(TrendsPage, { target: document.body });
118142
await flushPromises();

0 commit comments

Comments
 (0)