From 724cd77c32b0266dc624415a3fdec98fd52bf38f Mon Sep 17 00:00:00 2001 From: Alexander Cheryomukhin Date: Sun, 26 Jul 2026 13:29:10 +0300 Subject: [PATCH] Refactor API GET /api/v1/workouts endpoint --- CHANGELOG.md | 2 + api/docs/docs.go | 36 +++- api/docs/swagger.json | 36 +++- api/docs/swagger.yaml | 30 ++- api/v1/api_test.go | 66 +++++- api/v1/app.go | 4 + api/v1/workouts.go | 56 ++++-- internal/federation/repository.go | 1 + internal/federation/workout_inbox.go | 27 ++- internal/storage/file/workouts.go | 81 ++++++-- internal/storage/file/workouts_store_test.go | 42 ++++ internal/workouts/feed.go | 200 ++++++++++++++----- internal/workouts/feed_test.go | 141 +++++++++++++ internal/workouts/page.go | 105 ++++++++++ internal/workouts/page_test.go | 67 +++++++ internal/workouts/repository.go | 3 + internal/workouts/service.go | 23 ++- ui/grom/lib/api_request.dart | 23 ++- ui/grom/lib/models/workout.dart | 30 +++ ui/grom/lib/widgets/workout_feed_list.dart | 86 ++++++-- ui/grom/test/api_request_test.dart | 40 +++- 21 files changed, 970 insertions(+), 129 deletions(-) create mode 100644 internal/workouts/page.go create mode 100644 internal/workouts/page_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4401dcf..98cbe15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ 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`) @@ -23,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/api/docs/docs.go b/api/docs/docs.go index d287f4c..65df5eb 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -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" ], @@ -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": { @@ -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": { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 05c0cfd..5c80930 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -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" ], @@ -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": { @@ -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": { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 3b2cc31..b60076d 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -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: @@ -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: diff --git a/api/v1/api_test.go b/api/v1/api_test.go index 687e57f..5a6b30b 100644 --- a/api/v1/api_test.go +++ b/api/v1/api_test.go @@ -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 { @@ -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) } @@ -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) } } @@ -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) } @@ -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 diff --git a/api/v1/app.go b/api/v1/app.go index 4d4bdb2..faa17fe 100644 --- a/api/v1/app.go +++ b/api/v1/app.go @@ -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) +} diff --git a/api/v1/workouts.go b/api/v1/workouts.go index 1108b83..38ef610 100644 --- a/api/v1/workouts.go +++ b/api/v1/workouts.go @@ -86,6 +86,13 @@ type WorkoutAuthorResponse struct { AvatarURL string `json:"avatar_url,omitempty" example:"/api/v1/users/bob/avatar"` } +// WorkoutListResponse is a cursor-paginated workout list. +type WorkoutListResponse struct { + Items []WorkoutResponse `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` + HasMore bool `json:"has_more"` +} + type WorkoutResponse struct { ID string `json:"id" example:"38472901"` Owner string `json:"owner,omitempty" example:"solarwind"` @@ -899,18 +906,20 @@ func (a *App) getWorkoutMediaOriginal(ctx *gin.Context) { // listWorkouts godoc // @Summary List workouts -// @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). // @Tags workouts // @Produce json // @Security BearerAuth -// @Param scope query string false "feed (default) or own" -// @Success 200 {array} WorkoutResponse +// @Param scope query string false "feed (default) or own" +// @Param limit query int false "page size (default 20, max 100)" +// @Param cursor query string false "opaque cursor from previous page next_cursor" +// @Success 200 {object} WorkoutListResponse // @Failure 400 {object} ErrorResponse // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /workouts [get] func (a *App) listWorkouts(ctx *gin.Context) { - nickname, err := a.currentUserNickname(ctx) + nickname, err := a.currentUserNickname(ctx) if err != nil { ctx.JSON(http.StatusUnauthorized, ErrorResponse{Error: "user not found"}) return @@ -925,6 +934,25 @@ func (a *App) listWorkouts(ctx *gin.Context) { return } + limit := workouts.DefaultPageLimit + if raw := strings.TrimSpace(ctx.Query("limit")); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 { + ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "invalid limit"}) + return + } + limit = workouts.ClampLimit(parsed) + } + + var cursor *workouts.Cursor + if raw := strings.TrimSpace(ctx.Query("cursor")); raw != "" { + cursor, err = workouts.DecodeCursor(raw) + if err != nil { + ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "invalid cursor"}) + return + } + } + userID, err := a.currentUserID(ctx) if err != nil { ctx.JSON(http.StatusUnauthorized, ErrorResponse{Error: "invalid token"}) @@ -938,13 +966,13 @@ func (a *App) listWorkouts(ctx *gin.Context) { } feedSvc := a.newFeedService() - var items []workouts.FeedWorkout + var page workouts.Page if scope == "own" { - items, err = feedSvc.ListOwn(nickname, viewer.Name) + page, err = feedSvc.ListOwnPage(nickname, viewer.Name, cursor, limit) } else { - - follows, err := a.Social.ListFollowing(userID) + var follows []social.Follow + follows, err = a.Social.ListFollowing(userID) if err != nil { ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to list following"}) return @@ -969,16 +997,20 @@ func (a *App) listWorkouts(ctx *gin.Context) { }) } - items, err = feedSvc.ListFeed(nickname, viewer.Name, followedAuthors) + page, err = feedSvc.ListFeedPage(nickname, viewer.Name, followedAuthors, cursor, limit) } if err != nil { ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to list workouts"}) return } - response := make([]WorkoutResponse, 0, len(items)) - for i := range items { - response = append(response, toFeedWorkoutResponse(&items[i])) + response := WorkoutListResponse{ + Items: make([]WorkoutResponse, 0, len(page.Items)), + NextCursor: page.NextCursor, + HasMore: page.HasMore, + } + for i := range page.Items { + response.Items = append(response.Items, toFeedWorkoutResponse(&page.Items[i])) } ctx.JSON(http.StatusOK, response) diff --git a/internal/federation/repository.go b/internal/federation/repository.go index 44b3a3d..fe09d8c 100644 --- a/internal/federation/repository.go +++ b/internal/federation/repository.go @@ -35,6 +35,7 @@ type InboxRepository interface { Avatar(viewerNickname, ownerKey string) ([]byte, error) Get(viewerNickname, ownerNickname, workoutID string) (*workouts.FeedWorkout, error) List(viewerNickname string) ([]workouts.FeedWorkout, error) + ListPage(viewerNickname string, cursor *workouts.Cursor, limit int) ([]workouts.FeedWorkout, bool, error) } type Storage interface { diff --git a/internal/federation/workout_inbox.go b/internal/federation/workout_inbox.go index 0533fad..a65c3ef 100644 --- a/internal/federation/workout_inbox.go +++ b/internal/federation/workout_inbox.go @@ -502,7 +502,32 @@ func (s *WorkoutInboxStore) List(viewerNickname string) ([]workouts.FeedWorkout, } sort.Slice(items, func(i, j int) bool { - return items[i].StartDate.After(items[j].StartDate) + return workouts.FeedNewer(items[i].StartDate, items[i].ID, items[j].StartDate, items[j].ID) }) return items, nil } + +func (s *WorkoutInboxStore) ListPage(viewerNickname string, cursor *workouts.Cursor, limit int) ([]workouts.FeedWorkout, bool, error) { + if limit <= 0 { + limit = workouts.DefaultPageLimit + } + all, err := s.List(viewerNickname) + if err != nil { + return nil, false, err + } + filtered := make([]workouts.FeedWorkout, 0, limit) + for i := range all { + if !workouts.AfterCursor(all[i].StartDate, all[i].ID, cursor) { + continue + } + filtered = append(filtered, all[i]) + if len(filtered) > limit { + break + } + } + hasMore := len(filtered) > limit + if hasMore { + filtered = filtered[:limit] + } + return filtered, hasMore, nil +} diff --git a/internal/storage/file/workouts.go b/internal/storage/file/workouts.go index 9588cb0..a53959d 100644 --- a/internal/storage/file/workouts.go +++ b/internal/storage/file/workouts.go @@ -11,6 +11,7 @@ import ( "time" "github.com/solargate/grom/internal/data" + "github.com/solargate/grom/internal/storage/keys" "github.com/solargate/grom/internal/workouts" "gopkg.in/yaml.v3" ) @@ -34,9 +35,7 @@ func (s *WorkoutsStore) userDir(nickname string) string { } func workoutBaseName(startDate time.Time, id string) string { - iso := startDate.UTC().Format("2006-01-02T15:04:05Z") - iso = strings.ReplaceAll(iso, ":", "") - return iso + "-" + id + return keys.WorkoutDirName(startDate, id) } func workoutFileName(startDate time.Time, id string) string { @@ -252,6 +251,10 @@ func readWorkoutFromDir(dir string) (*workouts.Workout, error) { } func (s *WorkoutsStore) List(nickname string) ([]workouts.Workout, error) { + return s.listAll(nickname) +} + +func (s *WorkoutsStore) listAll(nickname string) ([]workouts.Workout, error) { wd := workoutsDir(s.userDir(nickname)) entries, err := os.ReadDir(wd) if err != nil { @@ -266,32 +269,68 @@ func (s *WorkoutsStore) List(nickname string) ([]workouts.Workout, error) { if !entry.IsDir() { continue } - - dirName := entry.Name() - dirPath := filepath.Join(wd, dirName) - filePath := filepath.Join(dirPath, dirName+".yaml") - data, err := os.ReadFile(filePath) + workout, err := readWorkoutFromDir(filepath.Join(wd, entry.Name())) if err != nil { - return nil, fmt.Errorf("read workout %q: %w", dirName, err) + return nil, fmt.Errorf("read workout %q: %w", entry.Name(), err) } + result = append(result, *workout) + } - var workout workouts.Workout - if err := yaml.Unmarshal(data, &workout); err != nil { - return nil, fmt.Errorf("parse workout %q: %w", dirName, err) - } - if workout.ID == "" { - if idx := strings.Index(dirName, "Z-"); idx >= 0 && idx+2 < len(dirName) { - workout.ID = dirName[idx+2:] - } + sort.Slice(result, func(i, j int) bool { + return workouts.FeedNewer(result[i].StartDate, result[i].ID, result[j].StartDate, result[j].ID) + }) + return result, nil +} + +func (s *WorkoutsStore) ListPage(nickname string, cursor *workouts.Cursor, limit int) ([]workouts.Workout, bool, error) { + if limit <= 0 { + limit = workouts.DefaultPageLimit + } + + wd := workoutsDir(s.userDir(nickname)) + entries, err := os.ReadDir(wd) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil } - result = append(result, workout) + return nil, false, fmt.Errorf("read workouts dir: %w", err) } - sort.Slice(result, func(i, j int) bool { - return result[i].StartDate.After(result[j].StartDate) + dirNames := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + dirNames = append(dirNames, entry.Name()) + } + } + sort.Slice(dirNames, func(i, j int) bool { + return dirNames[i] > dirNames[j] }) - return result, nil + var cursorKey string + if cursor != nil { + cursorKey = keys.WorkoutDirName(cursor.StartDate, cursor.ID) + } + + result := make([]workouts.Workout, 0, limit) + hasMore := false + for _, dirName := range dirNames { + if cursorKey != "" && dirName >= cursorKey { + continue + } + if len(result) >= limit { + hasMore = true + break + } + workout, err := readWorkoutFromDir(filepath.Join(wd, dirName)) + if err != nil { + return nil, false, fmt.Errorf("read workout %q: %w", dirName, err) + } + if cursor != nil && !workouts.AfterCursor(workout.StartDate, workout.ID, cursor) { + continue + } + result = append(result, *workout) + } + return result, hasMore, nil } func (s *WorkoutsStore) RemoveEquipmentFromAll(nickname, equipmentID string) error { diff --git a/internal/storage/file/workouts_store_test.go b/internal/storage/file/workouts_store_test.go index 5858c0f..d55a218 100644 --- a/internal/storage/file/workouts_store_test.go +++ b/internal/storage/file/workouts_store_test.go @@ -281,3 +281,45 @@ func TestWorkoutsStoreListMissingDir(t *testing.T) { t.Fatalf("List missing = %#v err=%v", list, err) } } + +func TestWorkoutsStoreListPage(t *testing.T) { + store := NewWorkoutsStore(t.TempDir()) + start := time.Date(2026, 7, 8, 15, 0, 0, 0, time.UTC) + ids := make([]string, 0, 5) + for i := 0; i < 5; i++ { + created, err := store.Create("alice", &workouts.Workout{ + Name: "W", SportType: "Run", StartDate: start.Add(-time.Duration(i) * time.Hour), + }) + if err != nil { + t.Fatal(err) + } + ids = append(ids, created.ID) + } + + page1, more, err := store.ListPage("alice", nil, 2) + if err != nil { + t.Fatal(err) + } + if len(page1) != 2 || !more { + t.Fatalf("page1 len=%d more=%v", len(page1), more) + } + if page1[0].ID != ids[0] || page1[1].ID != ids[1] { + t.Fatalf("unexpected page1 order: %#v want first %v", page1, ids[:2]) + } + + cursor := &workouts.Cursor{StartDate: page1[1].StartDate, ID: page1[1].ID} + page2, more, err := store.ListPage("alice", cursor, 2) + if err != nil { + t.Fatal(err) + } + if len(page2) != 2 || !more { + t.Fatalf("page2 len=%d more=%v", len(page2), more) + } + page3, more, err := store.ListPage("alice", &workouts.Cursor{StartDate: page2[1].StartDate, ID: page2[1].ID}, 2) + if err != nil { + t.Fatal(err) + } + if len(page3) != 1 || more { + t.Fatalf("page3 len=%d more=%v", len(page3), more) + } +} diff --git a/internal/workouts/feed.go b/internal/workouts/feed.go index 80afe15..315623f 100644 --- a/internal/workouts/feed.go +++ b/internal/workouts/feed.go @@ -1,6 +1,7 @@ package workouts import ( + "container/heap" "sort" "time" @@ -32,10 +33,12 @@ type FeedService struct { type workoutLister interface { List(nickname string) ([]Workout, error) + ListPage(nickname string, cursor *Cursor, limit int) ([]Workout, bool, error) } type FederatedWorkoutSource interface { ListFederated(viewerNickname string) ([]FeedWorkout, error) + ListFederatedPage(viewerNickname string, cursor *Cursor, limit int) ([]FeedWorkout, bool, error) } func NewFeedService(store workoutLister, blobs blob.Store, domain string) *FeedService { @@ -53,13 +56,9 @@ func (f *FeedService) SetFederatedSource(src FederatedWorkoutSource) { f.federated = src } -func (f *FeedService) ListOwn(viewerNickname, viewerName string) ([]FeedWorkout, error) { - own, err := f.store.List(viewerNickname) - if err != nil { - return nil, err - } +func (f *FeedService) viewerAuthor(viewerNickname, viewerName string) FeedAuthor { viewerHasAvatar, viewerAvatarURL := avatars.FieldsStore(f.blobs, viewerNickname) - viewerAuthor := FeedAuthor{ + return FeedAuthor{ Nickname: viewerNickname, Name: viewerName, Handle: f.localHandle(viewerNickname), @@ -67,6 +66,14 @@ func (f *FeedService) ListOwn(viewerNickname, viewerName string) ([]FeedWorkout, HasAvatar: viewerHasAvatar, AvatarURL: viewerAvatarURL, } +} + +func (f *FeedService) ListOwn(viewerNickname, viewerName string) ([]FeedWorkout, error) { + own, err := f.store.List(viewerNickname) + if err != nil { + return nil, err + } + viewerAuthor := f.viewerAuthor(viewerNickname, viewerName) result := make([]FeedWorkout, len(own)) for i := range own { result[i] = FeedWorkout{ @@ -76,76 +83,171 @@ func (f *FeedService) ListOwn(viewerNickname, viewerName string) ([]FeedWorkout, } } sort.Slice(result, func(i, j int) bool { - return result[i].StartDate.After(result[j].StartDate) + return FeedNewer(result[i].StartDate, result[i].ID, result[j].StartDate, result[j].ID) }) return result, nil } +// ListOwnPage returns a cursor page of the viewer's own workouts. +func (f *FeedService) ListOwnPage(viewerNickname, viewerName string, cursor *Cursor, limit int) (Page, error) { + limit = ClampLimit(limit) + own, hasMore, err := f.store.ListPage(viewerNickname, cursor, limit) + if err != nil { + return Page{}, err + } + viewerAuthor := f.viewerAuthor(viewerNickname, viewerName) + items := make([]FeedWorkout, len(own)) + for i := range own { + items[i] = FeedWorkout{ + Workout: own[i], + Author: viewerAuthor, + Owner: viewerNickname, + } + } + return buildPage(items, hasMore), nil +} + func (f *FeedService) ListFeed(viewerNickname, viewerName string, followedLocal []FeedAuthor) ([]FeedWorkout, error) { - type tagged struct { - workout FeedWorkout + var ( + items []FeedWorkout + cursor *Cursor + ) + for { + page, err := f.ListFeedPage(viewerNickname, viewerName, followedLocal, cursor, MaxPageLimit) + if err != nil { + return nil, err + } + items = append(items, page.Items...) + if !page.HasMore { + return items, nil + } + cursor, err = DecodeCursor(page.NextCursor) + if err != nil { + return nil, err + } } +} - items := make([]tagged, 0) +// ListFeedPage merges own, followed local, and federated sources with keyset pagination. +func (f *FeedService) ListFeedPage(viewerNickname, viewerName string, followedLocal []FeedAuthor, cursor *Cursor, limit int) (Page, error) { + limit = ClampLimit(limit) + viewerAuthor := f.viewerAuthor(viewerNickname, viewerName) - own, err := f.store.List(viewerNickname) + batches := make([]feedBatch, 0, 2+len(followedLocal)) + + own, ownMore, err := f.store.ListPage(viewerNickname, cursor, limit) if err != nil { - return nil, err - } - viewerHasAvatar, viewerAvatarURL := avatars.FieldsStore(f.blobs, viewerNickname) - viewerAuthor := FeedAuthor{ - Nickname: viewerNickname, - Name: viewerName, - Handle: f.localHandle(viewerNickname), - IsLocal: true, - HasAvatar: viewerHasAvatar, - AvatarURL: viewerAvatarURL, + return Page{}, err } + ownFeed := make([]FeedWorkout, len(own)) for i := range own { - items = append(items, tagged{ - workout: FeedWorkout{ - Workout: own[i], - Author: viewerAuthor, - Owner: viewerNickname, - }, - }) + ownFeed[i] = FeedWorkout{ + Workout: own[i], + Author: viewerAuthor, + Owner: viewerNickname, + } } + batches = append(batches, feedBatch{items: ownFeed, hasMore: ownMore}) for _, author := range followedLocal { - workouts, err := f.store.List(author.Nickname) + list, more, err := f.store.ListPage(author.Nickname, cursor, limit) if err != nil { - return nil, err + return Page{}, err } - for i := range workouts { - items = append(items, tagged{ - workout: FeedWorkout{ - Workout: workouts[i], - Author: author, - Owner: author.Nickname, - }, - }) + feedItems := make([]FeedWorkout, len(list)) + for i := range list { + feedItems[i] = FeedWorkout{ + Workout: list[i], + Author: author, + Owner: author.Nickname, + } } + batches = append(batches, feedBatch{items: feedItems, hasMore: more}) } if f.federated != nil { - remote, err := f.federated.ListFederated(viewerNickname) + remote, more, err := f.federated.ListFederatedPage(viewerNickname, cursor, limit) if err != nil { - return nil, err + return Page{}, err } - for i := range remote { - items = append(items, tagged{workout: remote[i]}) + batches = append(batches, feedBatch{items: remote, hasMore: more}) + } + + merged, hasMore := mergeFeedBatches(batches, limit) + return buildPage(merged, hasMore), nil +} + +func buildPage(items []FeedWorkout, hasMore bool) Page { + page := Page{Items: items, HasMore: hasMore} + if hasMore && len(items) > 0 { + page.NextCursor = CursorFromWorkout(items[len(items)-1].Workout).Encode() + } + return page +} + +type feedBatch struct { + items []FeedWorkout + hasMore bool +} + +type feedHeapItem struct { + workout FeedWorkout + batch int + index int +} + +type feedHeap []feedHeapItem + +func (h feedHeap) Len() int { return len(h) } +func (h feedHeap) Less(i, j int) bool { + return FeedNewer(h[i].workout.StartDate, h[i].workout.ID, h[j].workout.StartDate, h[j].workout.ID) +} +func (h feedHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *feedHeap) Push(x any) { *h = append(*h, x.(feedHeapItem)) } +func (h *feedHeap) Pop() any { + old := *h + n := len(old) + item := old[n-1] + *h = old[:n-1] + return item +} + +func mergeFeedBatches(batches []feedBatch, limit int) ([]FeedWorkout, bool) { + h := &feedHeap{} + heap.Init(h) + for bi, b := range batches { + if len(b.items) == 0 { + continue } + heap.Push(h, feedHeapItem{workout: b.items[0], batch: bi, index: 0}) } - sort.Slice(items, func(i, j int) bool { - return items[i].workout.StartDate.After(items[j].workout.StartDate) - }) + result := make([]FeedWorkout, 0, limit) + for h.Len() > 0 && len(result) < limit { + item := heap.Pop(h).(feedHeapItem) + result = append(result, item.workout) + next := item.index + 1 + if next < len(batches[item.batch].items) { + heap.Push(h, feedHeapItem{ + workout: batches[item.batch].items[next], + batch: item.batch, + index: next, + }) + } + } - result := make([]FeedWorkout, len(items)) - for i := range items { - result[i] = items[i].workout + if len(result) < limit { + return result, false } - return result, nil + if h.Len() > 0 { + return result, true + } + for _, b := range batches { + if b.hasMore { + return result, true + } + } + return result, false } func (f *FeedService) CanAccessWorkout(viewerNickname string, followedLocal []string, ownerNickname string) bool { diff --git a/internal/workouts/feed_test.go b/internal/workouts/feed_test.go index 7e79605..2980b9b 100644 --- a/internal/workouts/feed_test.go +++ b/internal/workouts/feed_test.go @@ -1,6 +1,7 @@ package workouts_test import ( + "sort" "testing" "time" @@ -107,6 +108,146 @@ func (s stubFederatedSource) ListFederated(viewerNickname string) ([]workouts.Fe return s.items, s.err } +func (s stubFederatedSource) ListFederatedPage(viewerNickname string, cursor *workouts.Cursor, limit int) ([]workouts.FeedWorkout, bool, error) { + if s.err != nil { + return nil, false, s.err + } + limit = workouts.ClampLimit(limit) + items := append([]workouts.FeedWorkout{}, s.items...) + sort.Slice(items, func(i, j int) bool { + return workouts.FeedNewer(items[i].StartDate, items[i].ID, items[j].StartDate, items[j].ID) + }) + out := make([]workouts.FeedWorkout, 0, limit) + for i := range items { + if !workouts.AfterCursor(items[i].StartDate, items[i].ID, cursor) { + continue + } + out = append(out, items[i]) + if len(out) > limit { + break + } + } + hasMore := len(out) > limit + if hasMore { + out = out[:limit] + } + return out, hasMore, nil +} + +func TestFeedServiceListOwnPage(t *testing.T) { + dir := t.TempDir() + svc := newTestService(dir) + blobs := blobfs.NewStore(dir) + + start := time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC) + for i := 0; i < 5; i++ { + _, err := svc.Create("alice", &workouts.Workout{ + Name: "Run", + SportType: "Run", + StartDate: start.Add(-time.Duration(i) * time.Hour), + }) + if err != nil { + t.Fatal(err) + } + } + + feed := workouts.NewFeedService(svc, blobs, "localhost") + page1, err := feed.ListOwnPage("alice", "Alice", nil, 2) + if err != nil { + t.Fatal(err) + } + if len(page1.Items) != 2 || !page1.HasMore || page1.NextCursor == "" { + t.Fatalf("page1 = %#v", page1) + } + + cursor, err := workouts.DecodeCursor(page1.NextCursor) + if err != nil { + t.Fatal(err) + } + page2, err := feed.ListOwnPage("alice", "Alice", cursor, 2) + if err != nil { + t.Fatal(err) + } + if len(page2.Items) != 2 || !page2.HasMore { + t.Fatalf("page2 = %#v", page2) + } + if page2.Items[0].ID == page1.Items[0].ID || page2.Items[0].ID == page1.Items[1].ID { + t.Fatalf("pages overlap: %v vs %v", page1.Items, page2.Items) + } + + cursor2, err := workouts.DecodeCursor(page2.NextCursor) + if err != nil { + t.Fatal(err) + } + page3, err := feed.ListOwnPage("alice", "Alice", cursor2, 2) + if err != nil { + t.Fatal(err) + } + if len(page3.Items) != 1 || page3.HasMore { + t.Fatalf("page3 = %#v", page3) + } +} + +func TestFeedServiceListFeedPageMergesSources(t *testing.T) { + dir := t.TempDir() + svc := newTestService(dir) + blobs := blobfs.NewStore(dir) + + start := time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC) + _, err := svc.Create("alice", &workouts.Workout{ + Name: "Alice late", SportType: "Run", StartDate: start, + }) + if err != nil { + t.Fatal(err) + } + _, err = svc.Create("bob", &workouts.Workout{ + Name: "Bob mid", SportType: "Ride", StartDate: start.Add(-time.Hour), + }) + if err != nil { + t.Fatal(err) + } + + feed := workouts.NewFeedService(svc, blobs, "localhost") + feed.SetFederatedSource(stubFederatedSource{items: []workouts.FeedWorkout{{ + Workout: workouts.Workout{ + ID: "99999999", Name: "Remote early", SportType: "Ride", + StartDate: start.Add(-2 * time.Hour), + }, + Author: workouts.FeedAuthor{Nickname: "remote", Handle: "remote@other", IsLocal: false}, + Owner: "remote@other", + }}}) + + page, err := feed.ListFeedPage("alice", "Alice", []workouts.FeedAuthor{ + {Nickname: "bob", Name: "Bob", Handle: "bob@localhost", IsLocal: true}, + }, nil, 2) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 2 || !page.HasMore { + t.Fatalf("expected 2 items with more, got %#v", page) + } + if page.Items[0].Name != "Alice late" || page.Items[1].Name != "Bob mid" { + t.Fatalf("unexpected order: %#v", page.Items) + } + + cursor, err := workouts.DecodeCursor(page.NextCursor) + if err != nil { + t.Fatal(err) + } + page2, err := feed.ListFeedPage("alice", "Alice", []workouts.FeedAuthor{ + {Nickname: "bob", Name: "Bob", Handle: "bob@localhost", IsLocal: true}, + }, cursor, 2) + if err != nil { + t.Fatal(err) + } + if len(page2.Items) != 1 || page2.HasMore { + t.Fatalf("page2 = %#v", page2) + } + if page2.Items[0].Name != "Remote early" { + t.Fatalf("expected remote leftover, got %#v", page2.Items[0]) + } +} + func TestFeedServiceMergesFederated(t *testing.T) { dir := t.TempDir() svc := newTestService(dir) diff --git a/internal/workouts/page.go b/internal/workouts/page.go new file mode 100644 index 0000000..ac9a352 --- /dev/null +++ b/internal/workouts/page.go @@ -0,0 +1,105 @@ +package workouts + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "time" +) + +const ( + DefaultPageLimit = 20 + MaxPageLimit = 100 +) + +// Cursor identifies a position in a start_date DESC, id DESC feed. +type Cursor struct { + StartDate time.Time `json:"t"` + ID string `json:"id"` +} + +// Page is a cursor-paginated list of feed workouts. +type Page struct { + Items []FeedWorkout + NextCursor string + HasMore bool +} + +// ClampLimit returns a safe page size (default 20, max 100). +func ClampLimit(limit int) int { + if limit <= 0 { + return DefaultPageLimit + } + if limit > MaxPageLimit { + return MaxPageLimit + } + return limit +} + +// CursorFromWorkout builds a cursor from a workout's sort key. +func CursorFromWorkout(w Workout) Cursor { + return Cursor{StartDate: w.StartDate.UTC(), ID: w.ID} +} + +// Encode serializes the cursor as a URL-safe opaque token. +func (c Cursor) Encode() string { + payload, err := json.Marshal(cursorJSON{ + T: c.StartDate.UTC().Format(time.RFC3339Nano), + ID: c.ID, + }) + if err != nil { + return "" + } + return base64.RawURLEncoding.EncodeToString(payload) +} + +// DecodeCursor parses an opaque cursor token. +func DecodeCursor(raw string) (*Cursor, error) { + if raw == "" { + return nil, nil + } + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("invalid cursor encoding") + } + var payload cursorJSON + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("invalid cursor payload") + } + if payload.ID == "" || payload.T == "" { + return nil, fmt.Errorf("invalid cursor fields") + } + t, err := time.Parse(time.RFC3339Nano, payload.T) + if err != nil { + t, err = time.Parse(time.RFC3339, payload.T) + if err != nil { + return nil, fmt.Errorf("invalid cursor time") + } + } + return &Cursor{StartDate: t.UTC(), ID: payload.ID}, nil +} + +type cursorJSON struct { + T string `json:"t"` + ID string `json:"id"` +} + +// FeedNewer reports whether a should appear before b in the feed +// (start_date DESC, id DESC). +func FeedNewer(aStart time.Time, aID string, bStart time.Time, bID string) bool { + aStart = aStart.UTC() + bStart = bStart.UTC() + if !aStart.Equal(bStart) { + return aStart.After(bStart) + } + return aID > bID +} + +// AfterCursor reports whether the workout is strictly older than the cursor +// in feed order (i.e. should appear on a later page). +func AfterCursor(start time.Time, id string, cursor *Cursor) bool { + if cursor == nil { + return true + } + return FeedNewer(cursor.StartDate, cursor.ID, start, id) +} diff --git a/internal/workouts/page_test.go b/internal/workouts/page_test.go new file mode 100644 index 0000000..2f2cac7 --- /dev/null +++ b/internal/workouts/page_test.go @@ -0,0 +1,67 @@ +package workouts_test + +import ( + "testing" + "time" + + "github.com/solargate/grom/internal/workouts" +) + +func TestCursorRoundTrip(t *testing.T) { + start := time.Date(2026, 7, 8, 10, 30, 0, 123, time.UTC) + c := workouts.CursorFromWorkout(workouts.Workout{ID: "38472901", StartDate: start}) + encoded := c.Encode() + if encoded == "" { + t.Fatal("expected non-empty cursor") + } + decoded, err := workouts.DecodeCursor(encoded) + if err != nil { + t.Fatal(err) + } + if decoded.ID != "38472901" { + t.Fatalf("id = %q", decoded.ID) + } + if !decoded.StartDate.Equal(start.UTC()) { + t.Fatalf("start = %v want %v", decoded.StartDate, start.UTC()) + } +} + +func TestDecodeCursorRejectsGarbage(t *testing.T) { + if _, err := workouts.DecodeCursor("not-a-cursor"); err == nil { + t.Fatal("expected error") + } +} + +func TestAfterCursor(t *testing.T) { + newer := time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC) + older := time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC) + cursor := &workouts.Cursor{StartDate: newer, ID: "20000000"} + + if workouts.AfterCursor(newer, "20000000", cursor) { + t.Fatal("cursor item itself must not be after cursor") + } + if workouts.AfterCursor(newer, "30000000", cursor) { + t.Fatal("newer id at same time must not be after cursor") + } + if !workouts.AfterCursor(newer, "10000000", cursor) { + t.Fatal("older id at same time should be after cursor") + } + if !workouts.AfterCursor(older, "99999999", cursor) { + t.Fatal("older timestamp should be after cursor") + } + if !workouts.AfterCursor(newer, "20000000", nil) { + t.Fatal("nil cursor should accept all") + } +} + +func TestClampLimit(t *testing.T) { + if got := workouts.ClampLimit(0); got != workouts.DefaultPageLimit { + t.Fatalf("default: got %d", got) + } + if got := workouts.ClampLimit(5); got != 5 { + t.Fatalf("passthrough: got %d", got) + } + if got := workouts.ClampLimit(1000); got != workouts.MaxPageLimit { + t.Fatalf("max: got %d", got) + } +} diff --git a/internal/workouts/repository.go b/internal/workouts/repository.go index a482538..6211f75 100644 --- a/internal/workouts/repository.go +++ b/internal/workouts/repository.go @@ -7,6 +7,9 @@ type Repository interface { Update(nickname string, workout *Workout) (*Workout, error) Get(nickname, workoutID string) (*Workout, error) List(nickname string) ([]Workout, error) + // ListPage returns up to limit workouts older than cursor (nil = first page), + // ordered by start_date DESC, id DESC. hasMore is true when more items exist. + ListPage(nickname string, cursor *Cursor, limit int) (items []Workout, hasMore bool, err error) Delete(nickname, workoutID string) error RemoveEquipmentFromAll(nickname, equipmentID string) error HasStravaActivityID(nickname, stravaActivityID string) (bool, error) diff --git a/internal/workouts/service.go b/internal/workouts/service.go index 1378d17..d9a86e9 100644 --- a/internal/workouts/service.go +++ b/internal/workouts/service.go @@ -29,12 +29,27 @@ func (s *Service) List(nickname string) ([]Workout, error) { if err != nil { return nil, err } + s.enrichList(nickname, items) + return items, nil +} + +// ListPage returns a cursor page of workouts for nickname (enriched). +func (s *Service) ListPage(nickname string, cursor *Cursor, limit int) ([]Workout, bool, error) { + limit = ClampLimit(limit) + items, hasMore, err := s.repo.ListPage(nickname, cursor, limit) + if err != nil { + return nil, false, err + } + s.enrichList(nickname, items) + return items, hasMore, nil +} + +func (s *Service) enrichList(nickname string, items []Workout) { byID := s.loadEquipmentByID(nickname, items) for i := range items { s.enrichWorkout(nickname, &items[i]) ApplyEquipmentCatalog(items[i].Equipment, byID) } - return items, nil } func (s *Service) Get(nickname, workoutID string) (*Workout, error) { @@ -119,10 +134,8 @@ func (s *Service) Update(nickname string, workoutID string, patch *Workout) (*Wo } func (s *Service) enrichWorkout(nickname string, w *Workout) { - dirName, err := s.repo.WorkoutDirName(nickname, w.ID) - if err != nil { - return - } + // Derive dir name from metadata — avoids O(N) directory scans per list item. + dirName := keys.WorkoutDirName(w.StartDate, w.ID) ctx := context.Background() if ok, _ := s.blobs.Exists(ctx, keys.WorkoutMapPreview(nickname, dirName)); ok { w.HasMapPreview = true diff --git a/ui/grom/lib/api_request.dart b/ui/grom/lib/api_request.dart index 766a2bc..65097b0 100644 --- a/ui/grom/lib/api_request.dart +++ b/ui/grom/lib/api_request.dart @@ -503,23 +503,30 @@ class ApiRequest { return match?.group(1); } - Future> listWorkouts( + Future listWorkouts( String token, { String scope = 'feed', + int limit = 20, + String? cursor, }) async { - final uri = _uri('/api/v1/workouts').replace( - queryParameters: scope == 'feed' ? null : {'scope': scope}, - ); + final params = { + 'limit': '$limit', + }; + if (scope != 'feed') { + params['scope'] = scope; + } + if (cursor != null && cursor.isNotEmpty) { + params['cursor'] = cursor; + } + final uri = _uri('/api/v1/workouts').replace(queryParameters: params); final response = await _client.get( uri, headers: {'Authorization': 'Bearer $token'}, ); if (response.statusCode == 200) { - final json = jsonDecode(response.body) as List; - return json - .map((item) => Workout.fromJson(item as Map)) - .toList(); + final json = jsonDecode(response.body) as Map; + return WorkoutListPage.fromJson(json); } throw _parseError(response); diff --git a/ui/grom/lib/models/workout.dart b/ui/grom/lib/models/workout.dart index ddfca68..6d46e35 100644 --- a/ui/grom/lib/models/workout.dart +++ b/ui/grom/lib/models/workout.dart @@ -170,3 +170,33 @@ class CreateWorkoutDraft { }; } } + +class WorkoutListPage { + WorkoutListPage({ + required this.items, + this.nextCursor, + this.hasMore = false, + }); + + final List items; + final String? nextCursor; + final bool hasMore; + + factory WorkoutListPage.fromJson(Map json) { + final rawItems = json['items']; + final items = []; + if (rawItems is List) { + for (final item in rawItems) { + if (item is Map) { + items.add(Workout.fromJson(item)); + } + } + } + final cursor = json['next_cursor']; + return WorkoutListPage( + items: items, + nextCursor: cursor is String && cursor.isNotEmpty ? cursor : null, + hasMore: json['has_more'] as bool? ?? false, + ); + } +} diff --git a/ui/grom/lib/widgets/workout_feed_list.dart b/ui/grom/lib/widgets/workout_feed_list.dart index 35db5e2..62a4cf9 100644 --- a/ui/grom/lib/widgets/workout_feed_list.dart +++ b/ui/grom/lib/widgets/workout_feed_list.dart @@ -34,36 +34,75 @@ class WorkoutFeedList extends StatefulWidget { } class WorkoutFeedListState extends State { + static const _pageLimit = 20; + final ApiRequest _api = ApiRequest(); List _workouts = []; bool _isLoading = false; + bool _isLoadingMore = false; + bool _hasMore = false; + String? _nextCursor; String? _error; String? _authToken; @override void initState() { super.initState(); - _loadWorkouts(); + widget.scrollController.addListener(_onScroll); + _loadWorkouts(reset: true); + } + + @override + void dispose() { + widget.scrollController.removeListener(_onScroll); + super.dispose(); } @override void didUpdateWidget(covariant WorkoutFeedList oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.scrollController != widget.scrollController) { + oldWidget.scrollController.removeListener(_onScroll); + widget.scrollController.addListener(_onScroll); + } if (widget.refreshToken != oldWidget.refreshToken || widget.nickname != oldWidget.nickname || widget.scope != oldWidget.scope) { - _loadWorkouts(); + _loadWorkouts(reset: true); } } - Future reload() => _loadWorkouts(); + Future reload() => _loadWorkouts(reset: true); + + void _onScroll() { + if (!_hasMore || _isLoadingMore || _isLoading) { + return; + } + final position = widget.scrollController.position; + if (position.pixels >= position.maxScrollExtent - 400) { + _loadWorkouts(reset: false); + } + } - Future _loadWorkouts() async { - setState(() { - _isLoading = true; - _error = null; - }); + Future _loadWorkouts({required bool reset}) async { + if (reset) { + setState(() { + _isLoading = true; + _isLoadingMore = false; + _error = null; + _nextCursor = null; + _hasMore = false; + }); + } else { + if (!_hasMore || _isLoadingMore || _nextCursor == null) { + return; + } + setState(() { + _isLoadingMore = true; + _error = null; + }); + } try { final token = await AuthStorage.getToken(); @@ -71,12 +110,24 @@ class WorkoutFeedListState extends State { throw ApiException('Not authenticated'); } - final workouts = await _api.listWorkouts(token, scope: widget.scope); + final page = await _api.listWorkouts( + token, + scope: widget.scope, + limit: _pageLimit, + cursor: reset ? null : _nextCursor, + ); if (!mounted) return; setState(() { - _workouts = workouts; + if (reset) { + _workouts = page.items; + } else { + _workouts = [..._workouts, ...page.items]; + } + _nextCursor = page.nextCursor; + _hasMore = page.hasMore && page.nextCursor != null; _authToken = token; _isLoading = false; + _isLoadingMore = false; }); widget.onAuthTokenLoaded?.call(token); } on ApiException catch (e) { @@ -84,6 +135,7 @@ class WorkoutFeedListState extends State { setState(() { _error = e.message; _isLoading = false; + _isLoadingMore = false; }); } catch (_) { if (!mounted) return; @@ -91,6 +143,7 @@ class WorkoutFeedListState extends State { setState(() { _error = l10n.failedToLoadWorkouts; _isLoading = false; + _isLoadingMore = false; }); } } @@ -113,7 +166,7 @@ class WorkoutFeedListState extends State { Text(_error!, textAlign: TextAlign.center), const SizedBox(height: 16), FilledButton( - onPressed: _loadWorkouts, + onPressed: () => _loadWorkouts(reset: true), child: Text(l10n.retry), ), ], @@ -136,11 +189,18 @@ class WorkoutFeedListState extends State { } final compact = isMobileClient; + final itemCount = _workouts.length + (_isLoadingMore ? 1 : 0); final listView = ListView.builder( controller: widget.scrollController, padding: compact ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 8), - itemCount: _workouts.length, + itemCount: itemCount, itemBuilder: (context, index) { + if (index >= _workouts.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ); + } final workout = _workouts[index]; return Column( mainAxisSize: MainAxisSize.min, @@ -165,7 +225,7 @@ class WorkoutFeedListState extends State { ); return RefreshIndicator( - onRefresh: _loadWorkouts, + onRefresh: () => _loadWorkouts(reset: true), child: compact ? ScrollConfiguration( behavior: ScrollConfiguration.of(context) diff --git a/ui/grom/test/api_request_test.dart b/ui/grom/test/api_request_test.dart index c6bc120..bea030b 100644 --- a/ui/grom/test/api_request_test.dart +++ b/ui/grom/test/api_request_test.dart @@ -105,7 +105,7 @@ void main() { ); }); - test('getWorkout requests owner query and parses body', () async { + test('getWorkout requests owner query and parses body', () async { await ServerStorage.saveBaseUrl('https://grom.example'); final client = MockClient((request) async { expect(request.method, 'GET'); @@ -145,6 +145,44 @@ void main() { expect(workout.name, 'Morning run'); }); + test('listWorkouts parses cursor page envelope', () async { + await ServerStorage.saveBaseUrl('https://grom.example'); + final client = MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/v1/workouts'); + expect(request.url.queryParameters['limit'], '20'); + expect(request.url.queryParameters['scope'], 'own'); + expect(request.url.queryParameters['cursor'], 'abc'); + return http.Response( + jsonEncode({ + 'items': [ + { + 'id': 'wid', + 'name': 'Morning run', + 'sport_type': 'Run', + 'start_date': '2026-07-08T10:00:00Z', + 'duration_seconds': 1800, + 'distance': 5000, + }, + ], + 'next_cursor': 'next', + 'has_more': true, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + final page = await ApiRequest(client: client).listWorkouts( + 'tok', + scope: 'own', + cursor: 'abc', + ); + expect(page.items, hasLength(1)); + expect(page.items.first.id, 'wid'); + expect(page.nextCursor, 'next'); + expect(page.hasMore, isTrue); + }); + test('getServerInfo parses JSON and falls back on errors', () async { await ServerStorage.saveBaseUrl('https://grom.example'); final okClient = MockClient((request) async {