Add rowing machine endpoint

This commit is contained in:
2026-03-04 14:22:05 +00:00
parent 1d4beca336
commit 095cd72946
5 changed files with 186 additions and 1 deletions

View File

@@ -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)
}