Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/beep-analytics/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>"},
{"stats", "beep-analytics stats [--site"},
{"stats", "Usage: beep-analytics stats"},
{"version", "beep-analytics version"},
}
for _, tt := range tests {
Expand Down
10 changes: 6 additions & 4 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):**
Expand Down Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand All @@ -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

Expand Down
34 changes: 32 additions & 2 deletions internal/api/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package api

import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"

"github.com/adventurehound/beep-analytics/internal/db"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
127 changes: 127 additions & 0 deletions internal/api/stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
})
}
}
22 changes: 21 additions & 1 deletion internal/cli/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
76 changes: 76 additions & 0 deletions internal/db/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading