diff --git a/cmd/beep-analytics/main_test.go b/cmd/beep-analytics/main_test.go index f3353d6..983ca77 100644 --- a/cmd/beep-analytics/main_test.go +++ b/cmd/beep-analytics/main_test.go @@ -74,7 +74,7 @@ func TestCommandHelp(t *testing.T) { {"list-ignored", "beep-analytics list-ignored [--server"}, {"generate-token", "beep-analytics generate-token [--server"}, {"revoke-token", "beep-analytics revoke-token "}, - {"stats", "beep-analytics stats [--site"}, + {"stats", "Usage: beep-analytics stats"}, {"version", "beep-analytics version"}, } for _, tt := range tests { diff --git a/docs/API.md b/docs/API.md index dbbf8d1..a8e7bf0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -252,9 +252,9 @@ Retrieve pageview statistics. **Query Parameters:** - `site` (optional): Filter by site domain -- `from` (optional): Start date (YYYY-MM-DD format) -- `to` (optional): End date (YYYY-MM-DD format) -- `last` (optional): Relative time period (24h, 7d, 30d) +- `from` (optional): Start date (YYYY-MM-DD). Without `to`, defaults to now. +- `to` (optional): End date (YYYY-MM-DD). Without `from`, defaults to beginning of time. +- `last` (optional): Relative time period (24h, 7d, 30d, 1mo, 3mo, 6mo) - `verbose` (optional): Set to "true" for detailed view **Response (Aggregate Mode):** @@ -289,8 +289,10 @@ Retrieve pageview statistics. **Default Behavior:** - If no time parameters are provided, defaults to last 24 hours +- If only `from` is provided, uses from that date to current time +- If only `to` is provided, uses from earliest record to that date - If `from` and `to` are provided, uses that date range -- If `last` is provided, uses relative time from now +- If `last` is provided, uses relative time from now (overrides from/to) **Status Codes:** - `200 OK`: Statistics data diff --git a/docs/CLI.md b/docs/CLI.md index cbd4ee2..0b8e279 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -295,9 +295,9 @@ View pageview statistics. - `--server URL`: API server URL - `--token TOKEN`: API token - `--site DOMAIN`: Filter by site domain -- `--last PERIOD`: Relative time period (24h, 7d, 30d) -- `--from DATE`: Start date (YYYY-MM-DD) -- `--to DATE`: End date (YYYY-MM-DD) +- `--last PERIOD`: Relative time period (24h, 7d, 30d, 1mo, 3mo, 6mo) +- `--from DATE`: Start date (YYYY-MM-DD). Without --to, goes from this date to now. +- `--to DATE`: End date (YYYY-MM-DD). Without --from, goes from all time to this date. - `--verbose`, `-v`: Show detailed view **Examples:** @@ -309,9 +309,18 @@ View pageview statistics. # Last 7 days ./beep-analytics stats --last 7d +# Last 3 months +./beep-analytics stats --last 3mo + # Specific date range ./beep-analytics stats --from 2024-01-01 --to 2024-01-31 +# From a date to now +./beep-analytics stats --from 2024-06-01 + +# All time up to a date +./beep-analytics stats --to 2024-06-01 + # Filter by site ./beep-analytics stats --site example.com diff --git a/internal/api/stats.go b/internal/api/stats.go index 586bb9a..2ebab39 100644 --- a/internal/api/stats.go +++ b/internal/api/stats.go @@ -2,7 +2,10 @@ package api import ( "encoding/json" + "fmt" "net/http" + "strconv" + "strings" "time" "github.com/adventurehound/beep-analytics/internal/db" @@ -38,9 +41,22 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid to date", http.StatusBadRequest) return } - to = to.Add(24*time.Hour - time.Second) // End of day + to = to.Add(24*time.Hour - time.Second) + } else if fromStr != "" { + from, err = time.Parse("2006-01-02", fromStr) + if err != nil { + http.Error(w, "invalid from date", http.StatusBadRequest) + return + } + to = time.Now() + } else if toStr != "" { + to, err = time.Parse("2006-01-02", toStr) + if err != nil { + http.Error(w, "invalid to date", http.StatusBadRequest) + return + } + to = to.Add(24*time.Hour - time.Second) } else { - // Default to last 24 hours to = time.Now() from = to.Add(-24 * time.Hour) } @@ -90,6 +106,20 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { } func parseDuration(s string) (time.Duration, error) { + if strings.HasSuffix(s, "mo") && len(s) > 2 { + numStr := s[:len(s)-2] + n, err := strconv.Atoi(numStr) + if err != nil { + return 0, fmt.Errorf("invalid month duration: %s", s) + } + if n <= 0 { + return 0, fmt.Errorf("invalid month duration: %s", s) + } + if n > 3558 { + return 0, fmt.Errorf("month duration too large: %s", s) + } + return time.Duration(n) * 30 * 24 * time.Hour, nil + } switch s { case "24h": return 24 * time.Hour, nil diff --git a/internal/api/stats_test.go b/internal/api/stats_test.go index 3cb73eb..160ba07 100644 --- a/internal/api/stats_test.go +++ b/internal/api/stats_test.go @@ -70,6 +70,32 @@ func TestStatsEndpoint(t *testing.T) { } } +func TestStatsFromOnly(t *testing.T) { + _, ts := setupTestServer(t) + token := generateTestToken(t, ts) + + req, _ := http.NewRequest("GET", ts.URL+"/api/stats?from=2024-01-01", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != 200 { + t.Errorf("from only: expected 200, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestStatsToOnly(t *testing.T) { + _, ts := setupTestServer(t) + token := generateTestToken(t, ts) + + req, _ := http.NewRequest("GET", ts.URL+"/api/stats?to=2024-01-31", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != 200 { + t.Errorf("to only: expected 200, got %d", resp.StatusCode) + } + resp.Body.Close() +} + func TestStatsDefaultTimeRange(t *testing.T) { _, ts := setupTestServer(t) token := generateTestToken(t, ts) @@ -175,6 +201,32 @@ func TestStats30DayRange(t *testing.T) { resp.Body.Close() } +func TestStats1MonthRange(t *testing.T) { + _, ts := setupTestServer(t) + token := generateTestToken(t, ts) + + req, _ := http.NewRequest("GET", ts.URL+"/api/stats?last=1mo", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != 200 { + t.Errorf("1mo range: expected 200, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestStats3MonthRange(t *testing.T) { + _, ts := setupTestServer(t) + token := generateTestToken(t, ts) + + req, _ := http.NewRequest("GET", ts.URL+"/api/stats?last=3mo", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != 200 { + t.Errorf("3mo range: expected 200, got %d", resp.StatusCode) + } + resp.Body.Close() +} + func TestStatsAllSites(t *testing.T) { _, ts := setupTestServer(t) token := generateTestToken(t, ts) @@ -228,3 +280,78 @@ func TestStatsAllSites(t *testing.T) { t.Errorf("expected both sites in results, got %v", sites) } } + +func TestStatsTimeRangeReturnsData(t *testing.T) { + _, ts := setupTestServer(t) + token := generateTestToken(t, ts) + + body := `{"domain":"example.com"}` + req, _ := http.NewRequest("POST", ts.URL+"/api/sites", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + http.DefaultClient.Do(req) + + collectBody := `{"origin":"https://example.com","path":"/","referrer":"","screen":"1920x1080"}` + req, _ = http.NewRequest("POST", ts.URL+"/collect", strings.NewReader(collectBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "https://example.com") + http.DefaultClient.Do(req) + + // Range that includes the data (last 24h) + req, _ = http.NewRequest("GET", ts.URL+"/api/stats?site=example.com&last=24h", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != 200 { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var stats []struct { + Site string `json:"site"` + Path string `json:"path"` + } + json.NewDecoder(resp.Body).Decode(&stats) + resp.Body.Close() + if len(stats) == 0 { + t.Error("expected data in last 24h range, got empty") + } + + // Range that excludes the data (year 2000) + req, _ = http.NewRequest("GET", ts.URL+"/api/stats?site=example.com&from=2000-01-01&to=2000-01-02", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, _ = http.DefaultClient.Do(req) + if resp.StatusCode != 200 { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + json.NewDecoder(resp.Body).Decode(&stats) + resp.Body.Close() + if len(stats) != 0 { + t.Errorf("expected no data in year 2000 range, got %d rows", len(stats)) + } +} + +func TestStatsInvalidMoDuration(t *testing.T) { + _, ts := setupTestServer(t) + token := generateTestToken(t, ts) + + tests := []struct { + name string + query string + }{ + {"bare mo", "last=mo"}, + {"zero months", "last=0mo"}, + {"negative months", "last=-1mo"}, + {"uppercase Mo", "last=12Mo"}, + {"overflow", "last=999999mo"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, _ := http.NewRequest("GET", ts.URL+"/api/stats?"+tt.query, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != 400 { + t.Errorf("%s: expected 400, got %d", tt.name, resp.StatusCode) + } + resp.Body.Close() + }) + } +} diff --git a/internal/cli/stats.go b/internal/cli/stats.go index bbc99ad..7cdb86d 100644 --- a/internal/cli/stats.go +++ b/internal/cli/stats.go @@ -10,7 +10,23 @@ import ( ) func CmdStats(args []string) { - checkHelp(args, "Usage: beep-analytics stats [--site DOMAIN] [--last DURATION] [--from TIME] [--to TIME] [--verbose] [--server URL] [--token TOKEN]") + checkHelp(args, `Usage: beep-analytics stats [options] + +Options: + --site DOMAIN Filter by site domain + --last PERIOD Relative period: 24h, 7d, 30d, 1mo, 3mo, 6mo (default: 24h) + --from DATE Start date (YYYY-MM-DD). Without --to, goes to now. + --to DATE End date (YYYY-MM-DD). Without --from, goes from all time. + --verbose, -v Show detailed view + +Examples: + beep-analytics stats Last 24 hours (default) + beep-analytics stats --last 7d Last 7 days + beep-analytics stats --last 3mo Last 3 months + beep-analytics stats --from 2024-01-01 From Jan 1 to now + beep-analytics stats --to 2024-06-01 All time up to June 1 + beep-analytics stats --from 2024-01-01 --to 2024-06-01 Specific range + beep-analytics stats --site example.com --last 30d Filter by site`) server, token, remaining := ParseGlobalFlags(args) var site, last, from, to string @@ -43,6 +59,10 @@ func CmdStats(args []string) { } } + if last != "" && (from != "" || to != "") { + fmt.Fprintf(os.Stderr, "Warning: --last overrides --from/--to; only --last will be used\n") + } + params := url.Values{} if site != "" { params.Set("site", site) diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 4bcdad1..f7c6a45 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -437,6 +437,44 @@ func TestGetAggregateStats(t *testing.T) { } } +func TestGetAggregateStatsFromOnly(t *testing.T) { + db := setupTestDB(t) + site, _ := db.AddSite("example.com") + db.InsertPageview(models.PageviewInput{SiteID: site.ID, IP: "1.2.3.4", Path: "/"}) + + q := StatsQuery{ + SiteID: site.ID, + To: time.Now().Add(time.Hour), + } + + rows, err := db.GetAggregateStats(q) + if err != nil { + t.Fatalf("get aggregate stats with zero from: %v", err) + } + if len(rows) != 1 { + t.Errorf("expected 1 stat row, got %d", len(rows)) + } +} + +func TestGetAggregateStatsToOnly(t *testing.T) { + db := setupTestDB(t) + site, _ := db.AddSite("example.com") + db.InsertPageview(models.PageviewInput{SiteID: site.ID, IP: "1.2.3.4", Path: "/"}) + + q := StatsQuery{ + SiteID: site.ID, + From: time.Now().Add(-24 * time.Hour), + } + + rows, err := db.GetAggregateStats(q) + if err != nil { + t.Fatalf("get aggregate stats with zero to: %v", err) + } + if len(rows) != 1 { + t.Errorf("expected 1 stat row, got %d", len(rows)) + } +} + func TestGetAggregateStatsAllSites(t *testing.T) { db := setupTestDB(t) @@ -513,6 +551,44 @@ func TestGetVerboseStats(t *testing.T) { } } +func TestGetVerboseStatsFromOnly(t *testing.T) { + db := setupTestDB(t) + site, _ := db.AddSite("example.com") + db.InsertPageview(models.PageviewInput{SiteID: site.ID, IP: "1.2.3.4", Path: "/"}) + + q := StatsQuery{ + SiteID: site.ID, + To: time.Now().Add(time.Hour), + } + + rows, err := db.GetVerboseStats(q) + if err != nil { + t.Fatalf("get verbose stats with zero from: %v", err) + } + if len(rows) != 1 { + t.Errorf("expected 1 row, got %d", len(rows)) + } +} + +func TestGetVerboseStatsToOnly(t *testing.T) { + db := setupTestDB(t) + site, _ := db.AddSite("example.com") + db.InsertPageview(models.PageviewInput{SiteID: site.ID, IP: "1.2.3.4", Path: "/"}) + + q := StatsQuery{ + SiteID: site.ID, + From: time.Now().Add(-24 * time.Hour), + } + + rows, err := db.GetVerboseStats(q) + if err != nil { + t.Fatalf("get verbose stats with zero to: %v", err) + } + if len(rows) != 1 { + t.Errorf("expected 1 row, got %d", len(rows)) + } +} + func TestGetVerboseStatsAllSites(t *testing.T) { db := setupTestDB(t) diff --git a/internal/db/stats.go b/internal/db/stats.go index 236ba37..cdf3d21 100644 --- a/internal/db/stats.go +++ b/internal/db/stats.go @@ -26,8 +26,17 @@ func (db *DB) GetAggregateStats(q StatsQuery) ([]models.StatsRow, error) { query := `SELECT s.domain, pv.ip, pv.path, COUNT(*) as count FROM pageviews pv JOIN sites s ON pv.site_id = s.id - WHERE pv.created_at >= ? AND pv.created_at <= ?` - args := []interface{}{q.From.UTC().Format("2006-01-02 15:04:05"), q.To.UTC().Format("2006-01-02 15:04:05")} + WHERE 1=1` + var args []interface{} + + if !q.From.IsZero() { + query += " AND pv.created_at >= ?" + args = append(args, q.From.UTC().Format("2006-01-02 15:04:05")) + } + if !q.To.IsZero() { + query += " AND pv.created_at <= ?" + args = append(args, q.To.UTC().Format("2006-01-02 15:04:05")) + } if q.SiteID > 0 { query += " AND pv.site_id = ?" @@ -57,8 +66,17 @@ func (db *DB) GetVerboseStats(q StatsQuery) ([]models.StatsRow, error) { query := `SELECT s.domain, pv.ip, pv.country, pv.region, pv.city, pv.browser, pv.os, pv.path, pv.referrer, pv.created_at FROM pageviews pv JOIN sites s ON pv.site_id = s.id - WHERE pv.created_at >= ? AND pv.created_at <= ?` - args := []interface{}{q.From.UTC().Format("2006-01-02 15:04:05"), q.To.UTC().Format("2006-01-02 15:04:05")} + WHERE 1=1` + var args []interface{} + + if !q.From.IsZero() { + query += " AND pv.created_at >= ?" + args = append(args, q.From.UTC().Format("2006-01-02 15:04:05")) + } + if !q.To.IsZero() { + query += " AND pv.created_at <= ?" + args = append(args, q.To.UTC().Format("2006-01-02 15:04:05")) + } if q.SiteID > 0 { query += " AND pv.site_id = ?"