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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Breaking:** `GET /api/v1/workouts` returns a cursor page `{items, next_cursor, has_more}` instead of a bare array; query params `limit` (default 20, max 100) and `cursor`
- **Breaking:** `GET /api/v1/server_info` renamed to `GET /api/v1/server-info`
- Server CLI moved to Cobra: root starts the server (`--config` / `-c`), `gencerts` is a subcommand; `--help` and `--version` are available
- Relative TLS cert/key, federation CA, and autocert `cache_dir` paths resolve against the grom binary directory (same as `storage.location` / `temp_dir`)
- Newly allocated workout IDs are unique across all local users on the instance (existing data is left unchanged)

### Fixed

- Workout list enrichment no longer re-scans the workouts directory per item (O(N²) on large libraries)
- Workout create responses now report `has_map_preview` correctly after track attach and after adding photos

## [0.1.0] - 2026-07-18
Expand Down
36 changes: 31 additions & 5 deletions api/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "Return workouts for the authenticated user sorted by start date descending. Use scope=feed for the full feed (default) or scope=own for only the viewer's workouts.",
"description": "Return a cursor page of workouts for the authenticated user sorted by start date descending (id descending tie-breaker). Use scope=feed for the full feed (default) or scope=own for only the viewer's workouts. Default limit is 20 (max 100).",
"produces": [
"application/json"
],
Expand All @@ -958,16 +958,25 @@ const docTemplate = `{
"description": "feed (default) or own",
"name": "scope",
"in": "query"
},
{
"type": "integer",
"description": "page size (default 20, max 100)",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "opaque cursor from previous page next_cursor",
"name": "cursor",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.WorkoutResponse"
}
"$ref": "#/definitions/v1.WorkoutListResponse"
}
},
"400": {
Expand Down Expand Up @@ -2036,6 +2045,23 @@ const docTemplate = `{
}
}
},
"v1.WorkoutListResponse": {
"type": "object",
"properties": {
"has_more": {
"type": "boolean"
},
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.WorkoutResponse"
}
},
"next_cursor": {
"type": "string"
}
}
},
"v1.WorkoutResponse": {
"type": "object",
"properties": {
Expand Down
36 changes: 31 additions & 5 deletions api/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -938,7 +938,7 @@
"BearerAuth": []
}
],
"description": "Return workouts for the authenticated user sorted by start date descending. Use scope=feed for the full feed (default) or scope=own for only the viewer's workouts.",
"description": "Return a cursor page of workouts for the authenticated user sorted by start date descending (id descending tie-breaker). Use scope=feed for the full feed (default) or scope=own for only the viewer's workouts. Default limit is 20 (max 100).",
"produces": [
"application/json"
],
Expand All @@ -952,16 +952,25 @@
"description": "feed (default) or own",
"name": "scope",
"in": "query"
},
{
"type": "integer",
"description": "page size (default 20, max 100)",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "opaque cursor from previous page next_cursor",
"name": "cursor",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.WorkoutResponse"
}
"$ref": "#/definitions/v1.WorkoutListResponse"
}
},
"400": {
Expand Down Expand Up @@ -2030,6 +2039,23 @@
}
}
},
"v1.WorkoutListResponse": {
"type": "object",
"properties": {
"has_more": {
"type": "boolean"
},
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.WorkoutResponse"
}
},
"next_cursor": {
"type": "string"
}
}
},
"v1.WorkoutResponse": {
"type": "object",
"properties": {
Expand Down
30 changes: 24 additions & 6 deletions api/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,17 @@ definitions:
example: bike
type: string
type: object
v1.WorkoutListResponse:
properties:
has_more:
type: boolean
items:
items:
$ref: '#/definitions/v1.WorkoutResponse'
type: array
next_cursor:
type: string
type: object
v1.WorkoutResponse:
properties:
author:
Expand Down Expand Up @@ -1102,23 +1113,30 @@ paths:
- users
/workouts:
get:
description: Return workouts for the authenticated user sorted by start date
descending. Use scope=feed for the full feed (default) or scope=own for only
the viewer's workouts.
description: Return a cursor page of workouts for the authenticated user sorted
by start date descending (id descending tie-breaker). Use scope=feed for the
full feed (default) or scope=own for only the viewer's workouts. Default limit
is 20 (max 100).
parameters:
- description: feed (default) or own
in: query
name: scope
type: string
- description: page size (default 20, max 100)
in: query
name: limit
type: integer
- description: opaque cursor from previous page next_cursor
in: query
name: cursor
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/v1.WorkoutResponse'
type: array
$ref: '#/definitions/v1.WorkoutListResponse'
"400":
description: Bad Request
schema:
Expand Down
66 changes: 63 additions & 3 deletions api/v1/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,23 @@ func decodeList(t *testing.T, w *httptest.ResponseRecorder) []map[string]any {
return out
}

func decodeWorkoutPage(t *testing.T, w *httptest.ResponseRecorder) (items []map[string]any, nextCursor string, hasMore bool) {
t.Helper()
obj := decodeObject(t, w)
rawItems, _ := obj["items"].([]any)
items = make([]map[string]any, 0, len(rawItems))
for _, item := range rawItems {
m, ok := item.(map[string]any)
if !ok {
t.Fatalf("page item is not object: %#v", item)
}
items = append(items, m)
}
nextCursor, _ = obj["next_cursor"].(string)
hasMore, _ = obj["has_more"].(bool)
return items, nextCursor, hasMore
}

func expectStatus(t *testing.T, w *httptest.ResponseRecorder, want int) {
t.Helper()
if w.Code != want {
Expand Down Expand Up @@ -349,7 +366,7 @@ func TestWorkoutCRUDAndList(t *testing.T) {

w = ta.doJSON(t, http.MethodGet, "/api/v1/workouts?scope=own", nil, token)
expectStatus(t, w, http.StatusOK)
list := decodeList(t, w)
list, _, _ := decodeWorkoutPage(t, w)
if len(list) != 1 || list[0]["id"] != id || list[0]["name"] != "Evening run" {
t.Fatalf("unexpected list after update: %#v", list)
}
Expand All @@ -366,7 +383,7 @@ func TestWorkoutCRUDAndList(t *testing.T) {

w = ta.doJSON(t, http.MethodGet, "/api/v1/workouts?scope=own", nil, token)
expectStatus(t, w, http.StatusOK)
if list = decodeList(t, w); len(list) != 0 {
if list, _, _ = decodeWorkoutPage(t, w); len(list) != 0 {
t.Fatalf("expected empty list after delete, got %#v", list)
}
}
Expand Down Expand Up @@ -465,7 +482,7 @@ func TestEquipmentDeleteCascades(t *testing.T) {

w = ta.doJSON(t, http.MethodGet, "/api/v1/workouts?scope=own", nil, token)
expectStatus(t, w, http.StatusOK)
list := decodeList(t, w)
list, _, _ := decodeWorkoutPage(t, w)
if len(list) != 1 {
t.Fatalf("expected workout to remain, got %#v", list)
}
Expand Down Expand Up @@ -637,5 +654,48 @@ func TestAvatarUploadAPI(t *testing.T) {
expectStatus(t, w, http.StatusBadRequest)
}

func TestListWorkoutsPagination(t *testing.T) {
ta := setupTestApp(t)
ta.register(t, "alice", "alice@example.com", "password12")
token, _ := ta.login(t, "alice@example.com", "password12")

start := time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC)
for i := 0; i < 5; i++ {
w := ta.doJSON(t, http.MethodPost, "/api/v1/workouts", map[string]any{
"name": "Run",
"sport_type": "Run",
"start_date": start.Add(-time.Duration(i) * time.Hour).Format(time.RFC3339),
}, token)
expectStatus(t, w, http.StatusCreated)
}

w := ta.doJSON(t, http.MethodGet, "/api/v1/workouts?scope=own&limit=2", nil, token)
expectStatus(t, w, http.StatusOK)
items, cursor, hasMore := decodeWorkoutPage(t, w)
if len(items) != 2 || !hasMore || cursor == "" {
t.Fatalf("page1 items=%d hasMore=%v cursor=%q", len(items), hasMore, cursor)
}

w = ta.doJSON(t, http.MethodGet, "/api/v1/workouts?scope=own&limit=2&cursor="+cursor, nil, token)
expectStatus(t, w, http.StatusOK)
items2, cursor2, hasMore2 := decodeWorkoutPage(t, w)
if len(items2) != 2 || !hasMore2 || cursor2 == "" {
t.Fatalf("page2 items=%d hasMore=%v cursor=%q", len(items2), hasMore2, cursor2)
}
if items2[0]["id"] == items[0]["id"] || items2[0]["id"] == items[1]["id"] {
t.Fatalf("pages overlap: %#v %#v", items, items2)
}

w = ta.doJSON(t, http.MethodGet, "/api/v1/workouts?scope=own&limit=2&cursor="+cursor2, nil, token)
expectStatus(t, w, http.StatusOK)
items3, _, hasMore3 := decodeWorkoutPage(t, w)
if len(items3) != 1 || hasMore3 {
t.Fatalf("page3 items=%d hasMore=%v", len(items3), hasMore3)
}

w = ta.doJSON(t, http.MethodGet, "/api/v1/workouts?scope=own&cursor=not-a-valid-cursor", nil, token)
expectStatus(t, w, http.StatusBadRequest)
}

// Ensure storage.Open path used by NewApp is exercised via setup.
var _ = storage.Open
4 changes: 4 additions & 0 deletions api/v1/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,7 @@ type federatedFeedAdapter struct {
func (a federatedFeedAdapter) ListFederated(viewerNickname string) ([]workouts.FeedWorkout, error) {
return a.store.List(viewerNickname)
}

func (a federatedFeedAdapter) ListFederatedPage(viewerNickname string, cursor *workouts.Cursor, limit int) ([]workouts.FeedWorkout, bool, error) {
return a.store.ListPage(viewerNickname, cursor, limit)
}
Loading