lots of stuff, don't truly remember

This commit is contained in:
Blake Ridgway
2026-05-17 20:39:47 -05:00
parent 178ffb3425
commit dc4fe558b7
35 changed files with 3501 additions and 112 deletions

View File

@@ -5,6 +5,7 @@ import (
"log"
"net/http"
"strconv"
"strings"
"time"
"rideaware/internal/config"
@@ -37,15 +38,16 @@ func (h *Handler) CreateWorkout(w http.ResponseWriter, r *http.Request) {
log.Printf("UserID: %d", claims.UserID)
var req struct {
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
ScheduledDate string `json:"scheduled_date"`
Duration int `json:"duration"`
Notes string `json:"notes"`
WorkoutData *WorkoutDataJSON `json:"workout_data"`
FileType string `json:"file_type"`
EquipmentID *uint `json:"equipment_id"`
Duration int `json:"duration"`
Notes string `json:"notes"`
Tags Tags `json:"tags"`
WorkoutData *WorkoutDataJSON `json:"workout_data"`
FileType string `json:"file_type"`
EquipmentID *uint `json:"equipment_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -105,6 +107,7 @@ func (h *Handler) CreateWorkout(w http.ResponseWriter, r *http.Request) {
ScheduledDate: scheduledDate,
Duration: req.Duration,
Notes: req.Notes,
Tags: req.Tags,
FileType: req.FileType,
WorkoutData: *workoutData,
EquipmentID: req.EquipmentID,
@@ -127,11 +130,25 @@ func (h *Handler) CreateWorkout(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(workout)
}
// GetWorkouts GET /api/protected/workouts
// GetWorkouts GET /api/protected/workouts?tags=tag1,tag2
func (h *Handler) GetWorkouts(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
workouts, err := h.service.GetUserWorkouts(claims.UserID)
tagsParam := r.URL.Query().Get("tags")
var workouts []Workout
var err error
if tagsParam != "" {
tags := strings.Split(tagsParam, ",")
// Trim whitespace from each tag
for i := range tags {
tags[i] = strings.TrimSpace(tags[i])
}
workouts, err = h.service.GetUserWorkoutsByTags(claims.UserID, tags)
} else {
workouts, err = h.service.GetUserWorkouts(claims.UserID)
}
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
@@ -212,8 +229,10 @@ func (h *Handler) UpdateWorkout(w http.ResponseWriter, r *http.Request) {
MaxPower int `json:"max_power"`
MaxHR int `json:"max_hr"`
CaloriesBurned int `json:"calories_burned"`
RPE int `json:"rpe"`
Notes string `json:"notes"`
EquipmentID *uint `json:"equipment_id"`
Tags Tags `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -267,12 +286,18 @@ func (h *Handler) UpdateWorkout(w http.ResponseWriter, r *http.Request) {
if req.CaloriesBurned > 0 {
workout.CaloriesBurned = req.CaloriesBurned
}
if req.RPE > 0 && req.RPE <= 10 {
workout.RPE = req.RPE
}
if req.Notes != "" {
workout.Notes = req.Notes
}
if req.EquipmentID != nil {
workout.EquipmentID = req.EquipmentID
}
if req.Tags != nil {
workout.Tags = req.Tags
}
if err := h.service.repo.UpdateWorkout(workout); err != nil {
w.Header().Set("Content-Type", "application/json")
@@ -309,6 +334,74 @@ func (h *Handler) DeleteWorkout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// RemoveDuplicates POST /api/protected/workouts/remove-duplicates
func (h *Handler) RemoveDuplicates(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
removed, err := h.service.RemoveDuplicates(claims.UserID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "failed to remove duplicates"})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int64{"removed": removed})
}
// RescheduleWorkout PUT /api/protected/workouts/reschedule
func (h *Handler) RescheduleWorkout(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
idStr := r.URL.Query().Get("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid workout id"})
return
}
var req struct {
ScheduledDate string `json:"scheduled_date"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"})
return
}
newDate, err := time.Parse("2006-01-02", req.ScheduledDate)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid date format, use YYYY-MM-DD"})
return
}
workout, err := h.service.repo.GetWorkoutByID(uint(id), claims.UserID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "workout not found"})
return
}
workout.ScheduledDate = newDate
if err := h.service.repo.UpdateWorkout(workout); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "failed to reschedule workout"})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(workout)
}
// GetWorkoutTypes GET /api/protected/workout-types
func (h *Handler) GetWorkoutTypes(w http.ResponseWriter, r *http.Request) {
types := []map[string]interface{}{
@@ -414,4 +507,4 @@ func (h *Handler) UploadWorkoutFile(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(workout)
}
}

View File

@@ -3,6 +3,8 @@ package workout
import (
"database/sql/driver"
"encoding/json"
"fmt"
"strings"
"time"
)
@@ -26,11 +28,46 @@ type Workout struct {
EquipmentID *uint `gorm:"index" json:"equipment_id"`
FileURL string `gorm:"default:''" json:"file_url"`
WorkoutData WorkoutDataJSON `gorm:"type:jsonb" json:"workout_data,omitempty"`
RPE int `gorm:"default:0" json:"rpe"`
Notes string `json:"notes"`
Tags Tags `gorm:"type:jsonb;default:'[]'" json:"tags"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Tags is a JSONB array of string tags for workouts
type Tags []string
// Scan implements the sql.Scanner interface for reading from the database
func (t *Tags) Scan(value interface{}) error {
if value == nil {
*t = Tags{}
return nil
}
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, t)
case string:
return json.Unmarshal([]byte(v), t)
default:
return fmt.Errorf("unsupported type for Tags: %T", value)
}
}
// Value implements the driver.Valuer interface for writing to the database
func (t Tags) Value() (driver.Value, error) {
if t == nil {
return json.Marshal([]string{})
}
return json.Marshal(t)
}
// String returns a comma-separated string representation
func (t Tags) String() string {
return strings.Join(t, ", ")
}
type WorkoutDataJSON struct {
Name string `json:"name"`
Author string `json:"author"`
@@ -64,4 +101,4 @@ func (w WorkoutDataJSON) Value() (driver.Value, error) {
func (Workout) TableName() string {
return "workouts"
}
}

View File

@@ -39,6 +39,17 @@ func (r *Repository) GetUserWorkouts(userID uint) ([]Workout, error) {
return workouts, nil
}
func (r *Repository) GetUserWorkoutsByTags(userID uint, tags []string) ([]Workout, error) {
var workouts []Workout
// Use PostgreSQL jsonb ?| operator to check if the tags array contains any of the given tags
if err := database.DB.Where("user_id = ? AND tags ?| ?", userID, tags).
Order("scheduled_date DESC").
Find(&workouts).Error; err != nil {
return nil, err
}
return workouts, nil
}
func (r *Repository) GetWorkoutsByDateRange(userID uint, start, end time.Time) ([]Workout, error) {
var workouts []Workout
if err := database.DB.Where("user_id = ? AND scheduled_date BETWEEN ? AND ?", userID, start, end).
@@ -64,6 +75,33 @@ func (r *Repository) DeleteWorkout(id, userID uint) error {
Delete(&Workout{}).Error
}
func (r *Repository) RemoveDuplicates(userID uint) (int64, error) {
// Find IDs to keep: the minimum ID for each (title, scheduled_date, duration) group
// Delete all other workouts that are duplicates
result := database.DB.Exec(`
DELETE FROM workouts
WHERE user_id = ? AND id NOT IN (
SELECT MIN(id)
FROM workouts
WHERE user_id = ?
GROUP BY title, scheduled_date, duration
)
`, userID, userID)
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
func (r *Repository) GetCompletedWorkoutOnDate(userID uint, date string) (*Workout, error) {
var w Workout
if err := database.DB.Where("user_id = ? AND status = 'completed' AND DATE(scheduled_date) = ?", userID, date).
First(&w).Error; err != nil {
return nil, err
}
return &w, nil
}
type EquipmentStat struct {
EquipmentID uint `json:"equipment_id"`
TotalRides int `json:"total_rides"`
@@ -81,4 +119,4 @@ func (r *Repository) GetEquipmentStats(userID uint) ([]EquipmentStat, error) {
return nil, err
}
return stats, nil
}
}

View File

@@ -39,6 +39,13 @@ func (s *Service) GetUserWorkouts(userID uint) ([]Workout, error) {
return s.repo.GetUserWorkouts(userID)
}
func (s *Service) GetUserWorkoutsByTags(userID uint, tags []string) ([]Workout, error) {
if len(tags) == 0 {
return s.repo.GetUserWorkouts(userID)
}
return s.repo.GetUserWorkoutsByTags(userID, tags)
}
func (s *Service) GetWorkoutsByMonth(userID uint, year, month int) ([]Workout, error) {
return s.repo.GetWorkoutsByMonth(userID, year, month)
}
@@ -87,6 +94,10 @@ func (s *Service) DeleteWorkout(id, userID uint) error {
return s.repo.DeleteWorkout(id, userID)
}
func (s *Service) RemoveDuplicates(userID uint) (int64, error) {
return s.repo.RemoveDuplicates(userID)
}
func (s *Service) GetEquipmentStats(userID uint) ([]EquipmentStat, error) {
return s.repo.GetEquipmentStats(userID)
}
}