feat step through timeframes with +/- buttons

Pressing minus below range 1 steps down to the previous timeframe (day→hour). Pressing plus above max range steps up to the next timeframe (day→week). Buttons are only disabled at absolute extremes (hour/1 or year/max).

Commit
629d3c48d31d38a036d5efb80954bc47761bd9c8
Author
gpt-5.6-sol high <agent@openai.com>
Author date
Committer
gpt-5.6-sol high <agent@openai.com>
Committer date
Changed files
roles/dashboard/public/js/dashboard/graph-controls.js
index a209f3a2..3529a60b 100644..100644
@@ -32,11 +32,11 @@
32 32 });
33 33
34 34 document.querySelectorAll('[data-range-adjustment="decrease"]').forEach(button => {
35 Removed: button.disabled = range <= 1;
35 Added: button.disabled = timeframe.selected() === "hour" && range <= 1;
36 36 });
37 37
38 38 document.querySelectorAll('[data-range-adjustment="increase"]').forEach(button => {
39 Removed: button.disabled = range >= MAX_TIMEFRAME_RANGE;
39 Added: button.disabled = timeframe.selected() === "year" && range >= MAX_TIMEFRAME_RANGE;
40 40 });
41 41
42 42 updateRawDownloadLinks(timeframe);
roles/dashboard/public/js/dashboard/timeframe.js
index e9025b24..72d19d77 100644..100644
@@ -5,6 +5,7 @@
5 5 const initial = TIMEFRAMES[pageDefault] ? pageDefault : DEFAULT_TIMEFRAME;
6 6 let active = initial;
7 7 const ranges = Object.fromEntries(Object.keys(TIMEFRAMES).map(timeframe => [timeframe, 1]));
8 Added: const order = Object.keys(TIMEFRAMES);
8 9
9 10 function selected() {
10 11 return TIMEFRAMES[active] ? active : DEFAULT_TIMEFRAME;
@@ -27,10 +28,26 @@
27 28 if (!TIMEFRAMES[timeframe]) return;
28 29
29 30 active = timeframe;
30 Removed: ranges[timeframe] = Math.min(
31 Removed: MAX_TIMEFRAME_RANGE,
32 Removed: Math.max(1, (ranges[timeframe] || 1) + adjustment)
33 Removed: );
31 Added: const current = ranges[timeframe] || 1;
32 Added: const next = current + adjustment;
33 Added:
34 Added: if (next < 1) {
35 Added: // Step down to previous timeframe at range 1
36 Added: const index = order.indexOf(active);
37 Added: if (index > 0) {
38 Added: active = order[index - 1];
39 Added: ranges[active] = 1;
40 Added: }
41 Added: } else if (next > MAX_TIMEFRAME_RANGE) {
42 Added: // Step up to next timeframe at range 1
43 Added: const index = order.indexOf(active);
44 Added: if (index < order.length - 1) {
45 Added: active = order[index + 1];
46 Added: ranges[active] = 1;
47 Added: }
48 Added: } else {
49 Added: ranges[timeframe] = next;
50 Added: }
34 51 }
35 52 };
36 53 }
roles/dashboard/t/js/frontend.test.cjs
index 9a9d58d5..5cf4589d 100644..100644
@@ -57,6 +57,49 @@
57 57 assert.equal(timeframeToHours({ selected: () => "unknown", range: () => 1 }), 24);
58 58 });
59 59
60 Added: test("adjust steps to next/previous timeframe at range bounds", async () => {
61 Added: // We need a DOM element for createTimeframeState
62 Added: const { JSDOM } = await import("jsdom").catch(() => ({ JSDOM: null }));
63 Added:
64 Added: // Test the logic directly using a minimal mock
65 Added: const module = await dashboardModule("timeframe.js");
66 Added: const { createTimeframeState } = module.namespace;
67 Added:
68 Added: // createTimeframeState needs document.querySelector — skip if unavailable
69 Added: if (typeof globalThis.document === "undefined") {
70 Added: // Patch minimal document
71 Added: globalThis.document = { querySelector: () => ({ dataset: { defaultTimeframe: "day" } }) };
72 Added: }
73 Added:
74 Added: const state = createTimeframeState();
75 Added: assert.equal(state.selected(), "day");
76 Added: assert.equal(state.range(), 1);
77 Added:
78 Added: // Decrease below range 1 should step down to hour
79 Added: state.adjust("day", -1);
80 Added: assert.equal(state.selected(), "hour");
81 Added: assert.equal(state.range(), 1);
82 Added:
83 Added: // Decrease at hour/1 should stay at hour (lowest)
84 Added: state.adjust("hour", -1);
85 Added: assert.equal(state.selected(), "hour");
86 Added: assert.equal(state.range(), 1);
87 Added:
88 Added: // Increase from hour should increment range
89 Added: state.adjust("hour", 1);
90 Added: assert.equal(state.selected(), "hour");
91 Added: assert.equal(state.range(), 2);
92 Added:
93 Added: // Select day, then increase past MAX should step to week
94 Added: state.select("day");
95 Added: state.ranges.day = 24;
96 Added: state.adjust("day", 1);
97 Added: assert.equal(state.selected(), "week");
98 Added: assert.equal(state.range(), 1);
99 Added:
100 Added: delete globalThis.document;
101 Added: });
102 Added:
60 103 test("tooltipOptions returns independent callback configuration", async () => {
61 104 const module = await dashboardModule("chart-options.js");
62 105 const { TOOLTIP_DEFAULTS, tooltipOptions } = module.namespace;