From 1d4beca3367c9f7051a0e02d28dbe359eea509de Mon Sep 17 00:00:00 2001 From: Adam French Date: Wed, 4 Mar 2026 14:21:51 +0000 Subject: [PATCH 1/2] Add claude client to store --- backend/go.mod | 3 ++- backend/go.sum | 4 ++++ backend/handlers/store.go | 2 ++ backend/main.go | 14 ++++++++++++-- backend/services/claude.go | 15 +++++++++++++++ 5 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 backend/services/claude.go diff --git a/backend/go.mod b/backend/go.mod index 5be142f..7f13ee9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,12 +7,13 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/zmb3/spotify/v2 v2.4.3 golang.org/x/crypto v0.43.0 - golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 + golang.org/x/oauth2 v0.30.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 ) require ( + github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect diff --git a/backend/go.sum b/backend/go.sum index 952be2f..cfcba74 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -33,6 +33,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= +github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= @@ -287,6 +289,8 @@ golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 h1:Ati8dO7+U7mxpkPSxBZQEvzHVUYB/MqCklCN8ig5w/o= golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/backend/handlers/store.go b/backend/handlers/store.go index 9e487fc..9ed6f47 100644 --- a/backend/handlers/store.go +++ b/backend/handlers/store.go @@ -4,6 +4,7 @@ import ( "time" "adam-french.co.uk/backend/services" + "github.com/anthropics/anthropic-sdk-go" "github.com/zmb3/spotify/v2" spotifyauth "github.com/zmb3/spotify/v2/auth" "gorm.io/gorm" @@ -13,6 +14,7 @@ type Store struct { DB *gorm.DB SpotifyAuth *spotifyauth.Authenticator SpotifyClient *spotify.Client + ClaudeClient *anthropic.Client Auth *services.Auth Notes *services.Notes diff --git a/backend/main.go b/backend/main.go index 8e103aa..8deb386 100644 --- a/backend/main.go +++ b/backend/main.go @@ -40,12 +40,18 @@ func main() { log.Fatal(err) } + // SPOTIFY spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE") spotifyRedirectURL := os.Getenv("SPOTIFY_REDIRECT_URI") spotifyClientID := os.Getenv("SPOTIFY_CLIENT_ID") spotifyClientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET") spotifyConfig := services.SpotifyConfig{AuthState: spotifyAuthState, RedirectURL: spotifyRedirectURL, ClientID: spotifyClientID, ClientSecret: spotifyClientSecret} - spotifyAuth, client := services.InitSpotifyAuth(&spotifyConfig) + spotifyAuth, spotifyClient := services.InitSpotifyAuth(&spotifyConfig) + + // CLAUDE + claudeAPIKey := os.Getenv("CLAUDE_API_KEY") + claudeConfig := services.ClaudeConfig{APIKey: claudeAPIKey} + claudeClient := services.InitClaude(&claudeConfig) authSecret := os.Getenv("BACKEND_SECRET") domainName := os.Getenv("DOMAIN") @@ -59,7 +65,7 @@ func main() { notesConfig := services.NotesConfig{Dir: notesDir} notes := services.InitNotes(¬esConfig) - store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: client, Auth: auth, Notes: notes} + store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes} protected := r.Group("/", store.AuthMiddlewear) @@ -67,6 +73,10 @@ func main() { r.GET("/favorites", store.GetFavorites) protected.POST("/favorites", store.CreateFavorite) + // ROWING + r.GET("/rowing", store.GetRowing) + protected.POST("/rowing", store.CreateRowing) + // ACTIVITIES r.GET("/activity", store.GetActivity) protected.POST("/activity", store.CreateActivity) diff --git a/backend/services/claude.go b/backend/services/claude.go new file mode 100644 index 0000000..cb13dcc --- /dev/null +++ b/backend/services/claude.go @@ -0,0 +1,15 @@ +package services + +import ( + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" +) + +type ClaudeConfig struct { + APIKey string +} + +func InitClaude(config *ClaudeConfig) *anthropic.Client { + client := anthropic.NewClient(option.WithAPIKey(config.APIKey)) + return &client +} From 095cd72946daf85d17b526a02667677d0ca47557 Mon Sep 17 00:00:00 2001 From: Adam French Date: Wed, 4 Mar 2026 14:22:05 +0000 Subject: [PATCH 2/2] Add rowing machine endpoint --- backend/go.mod | 7 +- backend/go.sum | 14 +++ backend/handlers/handle_rowing.go | 154 ++++++++++++++++++++++++++++++ backend/models/models.go | 11 +++ backend/services/database.go | 1 + 5 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 backend/handlers/handle_rowing.go diff --git a/backend/go.mod b/backend/go.mod index 7f13ee9..9a73245 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,7 +24,7 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect - github.com/golang/protobuf v1.5.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -41,6 +41,11 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index cfcba74..29fe44d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -104,6 +104,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -174,6 +176,8 @@ github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1 github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -185,6 +189,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= diff --git a/backend/handlers/handle_rowing.go b/backend/handlers/handle_rowing.go new file mode 100644 index 0000000..07e9002 --- /dev/null +++ b/backend/handlers/handle_rowing.go @@ -0,0 +1,154 @@ +package handlers + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "time" + + "github.com/rwcarlsen/goexif/exif" + + "adam-french.co.uk/backend/models" + "github.com/anthropics/anthropic-sdk-go" + "github.com/gin-gonic/gin" +) + +type ExtractedRowingData struct { + TimeMinutes float64 `json:"timeMinutes"` + TimeSeconds float64 `json:"timeSeconds"` + Distance float64 `json:"distance"` +} + +func (store *Store) GetRowing(ctx *gin.Context) { + var rowing []models.Rowing + if err := store.DB.Order("Created_At DESC").Find(&rowing).Error; err != nil { + ctx.JSON(http.StatusInternalServerError, err.Error()) + return + } + ctx.JSON(http.StatusOK, rowing) +} + +func (store *Store) CreateRowing(ctx *gin.Context) { + file, err := ctx.FormFile("image") + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "image is required"}) + return + } + + f, err := file.Open() + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open image"}) + return + } + defer f.Close() + + // Get the date taken from the EXIF data + x, err := exif.Decode(f) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "no EXIF data found"}) + return + } + + dateTaken, err := x.DateTime() + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "no date found in EXIF data"}) + return + } + + // Seek back to start since exif.Decode advanced the cursor + if _, err := f.Seek(0, io.SeekStart); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to seek image"}) + return + } + + data, err := io.ReadAll(f) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read image"}) + return + } + + allowedMediaTypes := map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/gif": true, + "image/webp": true, + } + mediaType := file.Header.Get("Content-Type") + if !allowedMediaTypes[mediaType] { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image type"}) + return + } + encoded := base64.StdEncoding.EncodeToString(data) + + // Build the message with an image + text prompt + message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{ + Model: anthropic.ModelClaudeHaiku4_5, + MaxTokens: 256, + Messages: []anthropic.MessageParam{ + { + Role: "user", + Content: []anthropic.ContentBlockParamUnion{ + // Image block + anthropic.NewImageBlock(anthropic.Base64ImageSourceParam{ + Type: "base64", + MediaType: anthropic.Base64ImageSourceMediaType(mediaType), + Data: encoded, + }), + // Text prompt requesting exactly 2 variables + anthropic.NewTextBlock( + `Look at this rowing machine display. Extract the total elapsed time and total distance. + +Return ONLY a JSON object with these exact keys and numeric values: +- "timeMinutes": total minutes (e.g. 2:30 = 2) +- "timeSeconds": total seconds (e.g. 2:30 = 30) +- "distance": distance in meters as a number (e.g. 5000) + +No text, no markdown, no explanation. Just the JSON object.`), + }, + }, + }, + }) + + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to analyze image"}) + return + } + + if len(message.Content) == 0 { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from Claude"}) + return + } + + extractedData := ExtractedRowingData{} + err = json.Unmarshal([]byte(message.Content[0].Text), &extractedData) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse JSON response"}) + return + } + + if extractedData.Distance == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid distance in image"}) + return + } + + totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds + totalDuration := time.Duration(totalSeconds * float64(time.Second)) + per500m := time.Duration(totalSeconds / extractedData.Distance * 500 * float64(time.Second)) + + rowing := models.Rowing{ + Date: dateTaken, + Time: totalDuration, + TimePer500m: per500m, + Distance: extractedData.Distance, + Calories: extractedData.Distance / 7500.0 * 500.0, + } + + if err := store.DB.Create(&rowing).Error; err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save rowing"}) + return + } + + ctx.JSON(http.StatusCreated, rowing) +} diff --git a/backend/models/models.go b/backend/models/models.go index 4f43ead..f6b47b3 100644 --- a/backend/models/models.go +++ b/backend/models/models.go @@ -55,3 +55,14 @@ type Favorite struct { Name string `json:"name"` Link *string `json:"link"` } + +type Rowing struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` + Date time.Time `json:"date"` + Time time.Duration `json:"time"` + TimePer500m time.Duration `json:"timePer500m"` + Distance float64 `json:"distance"` + Calories float64 `json:"calories"` +} diff --git a/backend/services/database.go b/backend/services/database.go index 92b61dd..96641a1 100644 --- a/backend/services/database.go +++ b/backend/services/database.go @@ -36,6 +36,7 @@ func migrateDatabase(db *gorm.DB) error { &models.Post{}, &models.Activity{}, &models.Favorite{}, + &models.Rowing{}, ) if err != nil { return err