Skip to content

Commit f451e85

Browse files
committed
[TF-269] Add custom time range selector to Activity graph
Replace hardcoded 24h/3d/7d range options with a flexible duration parser using time.ParseDuration + day conversion (e.g. 30d, 2h30m). Ranges > 7d return daily-aggregated data points via DISTINCT ON for chart readability. Frontend adds inline custom range input alongside existing preset buttons.
1 parent 9c26f07 commit f451e85

16 files changed

Lines changed: 355 additions & 35 deletions

File tree

api/internal/handler/stats_test.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ type mockStatsRepo struct {
2121
snapshots []model.ProjectStatsSnapshot
2222
}
2323

24-
func (m *mockStatsRepo) Timeline(_ context.Context, projectID uuid.UUID, since time.Time) ([]model.ProjectStatsSnapshot, error) {
24+
func (m *mockStatsRepo) Timeline(_ context.Context, projectID uuid.UUID, since time.Time, daily bool) ([]model.ProjectStatsSnapshot, error) {
2525
var result []model.ProjectStatsSnapshot
2626
for _, s := range m.snapshots {
2727
if s.ProjectID == projectID && !s.CapturedAt.Before(since) {
@@ -122,7 +122,7 @@ func TestStatsHandler_Timeline_DefaultRange(t *testing.T) {
122122
}
123123
}
124124

125-
func TestStatsHandler_Timeline_InvalidRange(t *testing.T) {
125+
func TestStatsHandler_Timeline_CustomRange(t *testing.T) {
126126
h, _, info, projectKey := statsTestSetup(t)
127127

128128
req := httptest.NewRequest(http.MethodGet, "/api/v1/default/projects/"+projectKey+"/stats/timeline?range=30d", nil)
@@ -134,6 +134,23 @@ func TestStatsHandler_Timeline_InvalidRange(t *testing.T) {
134134

135135
h.Timeline(w, req)
136136

137+
if w.Code != http.StatusOK {
138+
t.Fatalf("expected 200 for custom range 30d, got %d: %s", w.Code, w.Body.String())
139+
}
140+
}
141+
142+
func TestStatsHandler_Timeline_InvalidRange(t *testing.T) {
143+
h, _, info, projectKey := statsTestSetup(t)
144+
145+
req := httptest.NewRequest(http.MethodGet, "/api/v1/default/projects/"+projectKey+"/stats/timeline?range=abc", nil)
146+
rctx := chi.NewRouteContext()
147+
rctx.URLParams.Add("projectKey", projectKey)
148+
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
149+
req = req.WithContext(model.ContextWithAuthInfo(req.Context(), info))
150+
w := httptest.NewRecorder()
151+
152+
h.Timeline(w, req)
153+
137154
if w.Code != http.StatusBadRequest {
138155
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
139156
}

api/internal/repository/stats.go

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -336,16 +336,31 @@ func (r *StatsRepository) backfillAtTime(ctx context.Context, t time.Time) (int6
336336
}
337337

338338
// Timeline returns project-level stats snapshots for the given project within
339-
// the time range [since, now], ordered chronologically.
340-
func (r *StatsRepository) Timeline(ctx context.Context, projectID uuid.UUID, since time.Time) ([]model.ProjectStatsSnapshot, error) {
341-
rows, err := r.db.QueryContext(ctx, `
342-
SELECT id, project_id, todo_count, in_progress_count, done_count, cancelled_count, captured_at
343-
FROM project_stats_snapshots
344-
WHERE project_id = $1
345-
AND user_id IS NULL
346-
AND captured_at >= $2
347-
ORDER BY captured_at ASC
348-
`, projectID, since)
339+
// the time range [since, now], ordered chronologically. When daily is true,
340+
// returns one data point per day (last snapshot of each day) for chart readability.
341+
func (r *StatsRepository) Timeline(ctx context.Context, projectID uuid.UUID, since time.Time, daily bool) ([]model.ProjectStatsSnapshot, error) {
342+
var query string
343+
if daily {
344+
query = `
345+
SELECT id, project_id, todo_count, in_progress_count, done_count, cancelled_count, captured_at
346+
FROM (
347+
SELECT DISTINCT ON (date_trunc('day', captured_at))
348+
id, project_id, todo_count, in_progress_count, done_count, cancelled_count, captured_at
349+
FROM project_stats_snapshots
350+
WHERE project_id = $1 AND user_id IS NULL AND captured_at >= $2
351+
ORDER BY date_trunc('day', captured_at) DESC, captured_at DESC
352+
) sub
353+
ORDER BY captured_at ASC`
354+
} else {
355+
query = `
356+
SELECT id, project_id, todo_count, in_progress_count, done_count, cancelled_count, captured_at
357+
FROM project_stats_snapshots
358+
WHERE project_id = $1
359+
AND user_id IS NULL
360+
AND captured_at >= $2
361+
ORDER BY captured_at ASC`
362+
}
363+
rows, err := r.db.QueryContext(ctx, query, projectID, since)
349364
if err != nil {
350365
return nil, fmt.Errorf("querying timeline: %w", err)
351366
}

api/internal/service/stats.go

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package service
33
import (
44
"context"
55
"fmt"
6+
"regexp"
7+
"strconv"
68
"time"
79

810
"github.com/google/uuid"
@@ -12,7 +14,7 @@ import (
1214

1315
// StatsRepository defines persistence operations for stats snapshots.
1416
type StatsRepository interface {
15-
Timeline(ctx context.Context, projectID uuid.UUID, since time.Time) ([]model.ProjectStatsSnapshot, error)
17+
Timeline(ctx context.Context, projectID uuid.UUID, since time.Time, daily bool) ([]model.ProjectStatsSnapshot, error)
1618
}
1719

1820
// StatsService handles stats business logic and authorization.
@@ -47,21 +49,46 @@ func (s *StatsService) Timeline(ctx context.Context, info *model.AuthInfo, proje
4749
return nil, fmt.Errorf("%s: %w", err.Error(), model.ErrValidation)
4850
}
4951

50-
return s.stats.Timeline(ctx, project.ID, since)
52+
daily := needsDailyGranularity(rangeStr)
53+
return s.stats.Timeline(ctx, project.ID, since, daily)
5154
}
5255

56+
var dayRe = regexp.MustCompile(`^(\d+)d$`)
57+
58+
const maxRange = 365 * 24 * time.Hour
59+
60+
// parseSince converts a range string like "24h", "3d", "30d", or "2h30m" into
61+
// a time.Time representing now minus that duration. Supports all time.ParseDuration
62+
// units plus day notation (<N>d → <N*24>h). Max range: 365d.
5363
func parseSince(rangeStr string) (time.Time, error) {
54-
now := time.Now()
55-
switch rangeStr {
56-
case "24h":
57-
return now.Add(-24 * time.Hour), nil
58-
case "3d":
59-
return now.Add(-3 * 24 * time.Hour), nil
60-
case "7d":
61-
return now.Add(-7 * 24 * time.Hour), nil
62-
default:
63-
return time.Time{}, fmt.Errorf("invalid range %q, must be 24h, 3d, or 7d", rangeStr)
64+
s := rangeStr
65+
if m := dayRe.FindStringSubmatch(s); m != nil {
66+
n, _ := strconv.Atoi(m[1])
67+
s = fmt.Sprintf("%dh", n*24)
68+
}
69+
d, err := time.ParseDuration(s)
70+
if err != nil {
71+
return time.Time{}, fmt.Errorf("invalid range %q", rangeStr)
72+
}
73+
if d <= 0 || d > maxRange {
74+
return time.Time{}, fmt.Errorf("range must be between 1s and 365d")
75+
}
76+
return time.Now().Add(-d), nil
77+
}
78+
79+
// needsDailyGranularity returns true if the range exceeds 7 days, meaning
80+
// the chart should display daily aggregated data instead of hourly.
81+
func needsDailyGranularity(rangeStr string) bool {
82+
s := rangeStr
83+
if m := dayRe.FindStringSubmatch(s); m != nil {
84+
n, _ := strconv.Atoi(m[1])
85+
s = fmt.Sprintf("%dh", n*24)
86+
}
87+
d, err := time.ParseDuration(s)
88+
if err != nil {
89+
return false
6490
}
91+
return d > 7*24*time.Hour
6592
}
6693

6794
func (s *StatsService) requireMembership(ctx context.Context, info *model.AuthInfo, projectID uuid.UUID) error {

api/internal/service/stats_test.go

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type mockStatsRepo struct {
1616
snapshots []model.ProjectStatsSnapshot
1717
}
1818

19-
func (m *mockStatsRepo) Timeline(_ context.Context, projectID uuid.UUID, since time.Time) ([]model.ProjectStatsSnapshot, error) {
19+
func (m *mockStatsRepo) Timeline(_ context.Context, projectID uuid.UUID, since time.Time, daily bool) ([]model.ProjectStatsSnapshot, error) {
2020
var result []model.ProjectStatsSnapshot
2121
for _, s := range m.snapshots {
2222
if s.ProjectID == projectID && !s.CapturedAt.Before(since) {
@@ -83,12 +83,81 @@ func TestStatsTimeline_InvalidRange(t *testing.T) {
8383
info := userAuthInfo()
8484
setupStatsProject(t, projectRepo, memberRepo, info, model.ProjectRoleMember)
8585

86-
_, err := svc.Timeline(context.Background(), info, "STAT", "30d")
86+
_, err := svc.Timeline(context.Background(), info, "STAT", "abc")
8787
if err == nil {
8888
t.Fatal("expected validation error for invalid range")
8989
}
9090
}
9191

92+
func TestStatsTimeline_CustomRange(t *testing.T) {
93+
svc, _, projectRepo, memberRepo := newTestStatsService()
94+
info := userAuthInfo()
95+
setupStatsProject(t, projectRepo, memberRepo, info, model.ProjectRoleMember)
96+
97+
// 30d should now be valid
98+
_, err := svc.Timeline(context.Background(), info, "STAT", "30d")
99+
if err != nil {
100+
t.Fatalf("expected no error for 30d, got %v", err)
101+
}
102+
}
103+
104+
func TestParseSince(t *testing.T) {
105+
tests := []struct {
106+
name string
107+
input string
108+
wantErr bool
109+
}{
110+
{"1 hour", "1h", false},
111+
{"24 hours", "24h", false},
112+
{"3 days", "3d", false},
113+
{"7 days", "7d", false},
114+
{"14 days", "14d", false},
115+
{"30 days", "30d", false},
116+
{"365 days", "365d", false},
117+
{"compound", "2h30m", false},
118+
{"30 minutes", "30m", false},
119+
{"empty", "", true},
120+
{"garbage", "abc", true},
121+
{"zero days", "0d", true},
122+
{"negative hours", "-5h", true},
123+
{"over max days", "400d", true},
124+
{"over max hours", "9000h", true},
125+
}
126+
127+
for _, tt := range tests {
128+
t.Run(tt.name, func(t *testing.T) {
129+
_, err := parseSince(tt.input)
130+
if (err != nil) != tt.wantErr {
131+
t.Errorf("parseSince(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
132+
}
133+
})
134+
}
135+
}
136+
137+
func TestNeedsDailyGranularity(t *testing.T) {
138+
tests := []struct {
139+
input string
140+
want bool
141+
}{
142+
{"7d", false},
143+
{"8d", true},
144+
{"168h", false},
145+
{"169h", true},
146+
{"24h", false},
147+
{"30d", true},
148+
{"3d", false},
149+
}
150+
151+
for _, tt := range tests {
152+
t.Run(tt.input, func(t *testing.T) {
153+
got := needsDailyGranularity(tt.input)
154+
if got != tt.want {
155+
t.Errorf("needsDailyGranularity(%q) = %v, want %v", tt.input, got, tt.want)
156+
}
157+
})
158+
}
159+
}
160+
92161
func TestStatsTimeline_NonMember(t *testing.T) {
93162
svc, _, projectRepo, _ := newTestStatsService()
94163
info := userAuthInfo()
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { test, expect } from '../../lib/fixtures';
2+
3+
async function attach(page: any, testInfo: any, name: string) {
4+
const screenshot = await page.screenshot();
5+
await testInfo.attach(name, { body: screenshot, contentType: 'image/png' });
6+
}
7+
8+
test.describe('Activity Graph Range Selector', () => {
9+
test('preset buttons render and are selectable', async ({ page, testProject }, testInfo) => {
10+
await page.goto(`/d/projects/${testProject.key}`);
11+
12+
// Verify preset buttons are visible
13+
const btn24h = page.getByRole('button', { name: '24h' });
14+
const btn3d = page.getByRole('button', { name: '3d' });
15+
const btn7d = page.getByRole('button', { name: '7d' });
16+
17+
await expect(btn24h).toBeVisible();
18+
await expect(btn3d).toBeVisible();
19+
await expect(btn7d).toBeVisible();
20+
21+
await attach(page, testInfo, '01-presets-visible');
22+
23+
// 7d should be active by default (has indigo background)
24+
await expect(btn7d).toHaveClass(/bg-indigo/);
25+
26+
// Click 24h and verify it becomes active
27+
await btn24h.click();
28+
await expect(btn24h).toHaveClass(/bg-indigo/);
29+
await expect(btn7d).not.toHaveClass(/bg-indigo/);
30+
31+
await attach(page, testInfo, '02-24h-selected');
32+
33+
// Click 3d and verify
34+
await btn3d.click();
35+
await expect(btn3d).toHaveClass(/bg-indigo/);
36+
await expect(btn24h).not.toHaveClass(/bg-indigo/);
37+
38+
await attach(page, testInfo, '03-3d-selected');
39+
});
40+
41+
test('custom range input is present and functional', async ({ page, testProject }, testInfo) => {
42+
await page.goto(`/d/projects/${testProject.key}`);
43+
44+
// Verify custom range inputs are visible
45+
const customInput = page.getByTestId('custom-range-value');
46+
const customUnit = page.getByTestId('custom-range-unit');
47+
48+
await expect(customInput).toBeVisible();
49+
await expect(customUnit).toBeVisible();
50+
51+
await attach(page, testInfo, '01-custom-input-visible');
52+
53+
// Set custom range to 14 days
54+
await customInput.fill('14');
55+
await customUnit.selectOption('d');
56+
57+
// Wait for the chart to load (no error should appear)
58+
await page.waitForTimeout(1000);
59+
60+
// Preset buttons should not have active style when custom is active
61+
const btn7d = page.getByRole('button', { name: '7d' });
62+
await expect(btn7d).not.toHaveClass(/bg-indigo/);
63+
64+
await attach(page, testInfo, '02-custom-14d-active');
65+
});
66+
67+
test('preset clears custom range', async ({ page, testProject }, testInfo) => {
68+
await page.goto(`/d/projects/${testProject.key}`);
69+
70+
const customInput = page.getByTestId('custom-range-value');
71+
const customUnit = page.getByTestId('custom-range-unit');
72+
73+
// Set custom range
74+
await customInput.fill('30');
75+
await customUnit.selectOption('d');
76+
await page.waitForTimeout(500);
77+
78+
// Click a preset — it should become active
79+
const btn7d = page.getByRole('button', { name: '7d' });
80+
await btn7d.click();
81+
await expect(btn7d).toHaveClass(/bg-indigo/);
82+
83+
await attach(page, testInfo, '01-preset-clears-custom');
84+
});
85+
86+
test('custom range 30d returns data without error', async ({ page, testProject }, testInfo) => {
87+
// Navigate to project overview
88+
await page.goto(`/d/projects/${testProject.key}`);
89+
90+
const customInput = page.getByTestId('custom-range-value');
91+
const customUnit = page.getByTestId('custom-range-unit');
92+
93+
// Set to 30 days
94+
await customInput.fill('30');
95+
await customUnit.selectOption('d');
96+
97+
// Verify the Activity heading is still visible (no error replaced the section)
98+
await expect(page.getByRole('heading', { name: 'Activity' })).toBeVisible();
99+
100+
// Verify either chart or "no activity data" message is shown (not an error)
101+
await expect(page.getByText('No activity data available yet.').or(page.locator('.recharts-responsive-container'))).toBeVisible();
102+
103+
await attach(page, testInfo, '01-30d-no-error');
104+
});
105+
});

web/src/api/stats.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export interface StatsTimelinePoint {
88
cancelled_count: number
99
}
1010

11-
export type StatsRange = '24h' | '3d' | '7d'
11+
export type StatsRange = string
1212

1313
export async function getStatsTimeline(
1414
projectKey: string,

0 commit comments

Comments
 (0)