From ef9d8de23514f081c15baada32d1f1c2e060738e Mon Sep 17 00:00:00 2001 From: Jo Vandeginste Date: Sun, 16 Jun 2024 23:21:11 +0200 Subject: [PATCH] Add other types of workouts without location Eg. add weight and repetition based workouts. Signed-off-by: Jo Vandeginste --- Makefile | 1 + assets/output.css | 16 ++ main.css | 4 + package-lock.json | 6 + package.json | 1 + pkg/app/app.go | 1 + pkg/app/routes.go | 1 + pkg/app/workouts.go | 69 ++++++++ pkg/app/workouts_handlers.go | 5 + pkg/database/equipment.go | 9 +- pkg/database/profile.go | 10 ++ pkg/database/workout_type.go | 103 +++++++++-- pkg/database/workouts.go | 8 + pkg/database/workouts_map.go | 30 ++-- views/equipment/equipment_show.html | 5 + .../user_profile_preferred_units.html | 15 ++ views/partials/workout_details.html | 17 ++ views/workouts/workout_form.html | 59 +++++++ views/workouts/workouts_add.html | 167 ++++++++++++------ views/workouts/workouts_show.html | 4 +- 20 files changed, 441 insertions(+), 90 deletions(-) create mode 100644 views/workouts/workout_form.html diff --git a/Makefile b/Makefile index 450a3eef..db4b0a84 100644 --- a/Makefile +++ b/Makefile @@ -64,6 +64,7 @@ build-dist: clean-dist cp -R ./node_modules/@fortawesome/fontawesome-free/ ./assets/dist/fontawesome/ cp -v ./node_modules/apexcharts/dist/apexcharts.min.js ./assets/dist/ cp -v ./node_modules/apexcharts/dist/apexcharts.css ./assets/dist/ + cp -v ./node_modules/htmx.org/dist/htmx.min.js ./assets/dist/ watch-tw: diff --git a/assets/output.css b/assets/output.css index bcb6bd8d..752d870f 100644 --- a/assets/output.css +++ b/assets/output.css @@ -579,6 +579,18 @@ button[type="submit"]:hover, } } +input:invalid { + --tw-bg-opacity: 1; + background-color: rgb(252 165 165 / var(--tw-bg-opacity)); +} + +@media (prefers-color-scheme: dark) { + input:invalid { + --tw-bg-opacity: 1; + background-color: rgb(153 27 27 / var(--tw-bg-opacity)); + } +} + button[type="submit"] { font-weight: 700; } @@ -2824,6 +2836,10 @@ table { content: "\f00d"; } +.\[a-zA-Z\:\\-\\\.\] { + a-z-a--z: \-\.; +} + @media (min-width: 640px) { .sm\:mt-0 { margin-top: 0px; diff --git a/main.css b/main.css index 33f807f7..eef24e0c 100644 --- a/main.css +++ b/main.css @@ -43,6 +43,10 @@ @apply bg-zinc-100 border-zinc-300 hover:border-amber-500 placeholder:text-slate-500; @apply dark:bg-zinc-900 dark:border-zinc-700 dark:hover:border-amber-500 dark:placeholder:text-slate-500; } + input:invalid { + @apply bg-red-300; + @apply dark:bg-red-800; + } button[type="submit"] { @apply font-bold; } diff --git a/package-lock.json b/package-lock.json index 3911db48..99f71852 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "@fortawesome/fontawesome-free": "^6.5.1", "apexcharts": "^3.48.0", "fullcalendar": "^6.1.11", + "htmx.org": "^1.9.12", "leaflet": "^1.9.4", "shareon": "^2.5.0", "sorttable": "^1.0.2", @@ -994,6 +995,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/htmx.org": { + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.12.tgz", + "integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", diff --git a/package.json b/package.json index 9b749a91..ddd4833b 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@fortawesome/fontawesome-free": "^6.5.1", "apexcharts": "^3.48.0", "fullcalendar": "^6.1.11", + "htmx.org": "^1.9.12", "leaflet": "^1.9.4", "shareon": "^2.5.0", "sorttable": "^1.0.2", diff --git a/pkg/app/app.go b/pkg/app/app.go index 676b5dbc..04d0e0c6 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -82,6 +82,7 @@ func (a *App) Configure() error { if err := a.ConfigureDatabase(); err != nil { return err } + if err := a.ConfigureGeocoder(); err != nil { return err } diff --git a/pkg/app/routes.go b/pkg/app/routes.go index 189d53ce..fd799c9d 100644 --- a/pkg/app/routes.go +++ b/pkg/app/routes.go @@ -138,6 +138,7 @@ func (a *App) secureRoutes(e *echo.Group) *echo.Group { workoutsGroup.POST("/:id/delete", a.workoutsDeleteHandler).Name = "workout-delete" workoutsGroup.POST("/:id/refresh", a.workoutsRefreshHandler).Name = "workout-refresh" workoutsGroup.GET("/add", a.workoutsAddHandler).Name = "workout-add" + workoutsGroup.GET("/form", a.workoutsFormHandler).Name = "workout-form" equipmentGroup := secureGroup.Group("/equipment") equipmentGroup.GET("", a.equipmentHandler).Name = "equipment" diff --git a/pkg/app/workouts.go b/pkg/app/workouts.go index 08ddc11d..3fa209f1 100644 --- a/pkg/app/workouts.go +++ b/pkg/app/workouts.go @@ -5,11 +5,17 @@ import ( "mime/multipart" "net/http" "strings" + "time" "github.com/jovandeginste/workout-tracker/pkg/database" "github.com/labstack/echo/v4" ) +const ( + htmlDateFormat = "2006-01-02T15:04" + htmlDurationFormat = "15:04" +) + func uploadedFile(file *multipart.FileHeader) ([]byte, error) { src, err := file.Open() if err != nil { @@ -26,7 +32,70 @@ func uploadedFile(file *multipart.FileHeader) ([]byte, error) { return content, nil } +type ManualWorkout struct { + Name string `form:"name"` + Date string `form:"date"` + Duration string `form:"duration"` + Repetitions int `form:"repetitions"` + Weight float64 `form:"weight"` + Notes string `form:"notes"` + Type database.WorkoutType `form:"type"` +} + +func (m *ManualWorkout) ToDate() time.Time { + d, err := time.Parse(htmlDateFormat, m.Date) + if err != nil { + return time.Time{} + } + + return d +} + +func (m *ManualWorkout) ToDuration() time.Duration { + d, err := time.Parse(htmlDurationFormat, m.Duration) + if err != nil { + return 0 + } + + return time.Duration(d.Hour())*time.Hour + time.Duration(d.Minute())*time.Minute +} + func (a *App) addWorkout(c echo.Context) error { + if strings.HasPrefix(c.Request().Header.Get(echo.HeaderContentType), echo.MIMEMultipartForm) { + return a.addWorkoutFromFile(c) + } + + d := &ManualWorkout{} + + if err := c.Bind(d); err != nil { + return a.redirectWithError(c, "/workouts", err) + } + + dDate := d.ToDate() + dDuration := d.ToDuration() + + w := database.Workout{ + Name: d.Name, + Notes: d.Notes, + Date: &dDate, + Type: d.Type, + User: a.getCurrentUser(c), + UserID: a.getCurrentUser(c).ID, + Data: &database.MapData{ + TotalDuration: dDuration, + TotalRepetitions: d.Repetitions, + TotalWeight: d.Weight, + }, + } + + if err := w.Save(a.db); err != nil { + return a.redirectWithError(c, "/workouts", err) + } + + return c.Redirect(http.StatusFound, a.echo.Reverse("workouts")) +} + +func (a *App) addWorkoutFromFile(c echo.Context) error { form, err := c.MultipartForm() if err != nil { return err diff --git a/pkg/app/workouts_handlers.go b/pkg/app/workouts_handlers.go index 130144ff..f0d5007f 100644 --- a/pkg/app/workouts_handlers.go +++ b/pkg/app/workouts_handlers.go @@ -44,6 +44,11 @@ func (a *App) workoutsAddHandler(c echo.Context) error { return c.Render(http.StatusOK, "workouts_add.html", data) } +func (a *App) workoutsFormHandler(c echo.Context) error { + t := database.WorkoutType(c.FormValue("type")) + return c.Render(http.StatusOK, "workout_form.html", t) +} + func (a *App) workoutsDeleteHandler(c echo.Context) error { //nolint:dupl workout, err := a.getWorkout(c) if err != nil { diff --git a/pkg/database/equipment.go b/pkg/database/equipment.go index cc81d57a..27bec8ef 100644 --- a/pkg/database/equipment.go +++ b/pkg/database/equipment.go @@ -87,12 +87,17 @@ func (e *Equipment) GetTotals() (WorkoutTotals, error) { if w.Type.IsDuration() { rs.Duration += w.Duration() } + + if w.Type.IsRepetition() { + rs.Repetitions += w.Repetitions() + } } return rs, nil } type WorkoutTotals struct { - Distance float64 - Duration time.Duration + Distance float64 + Duration time.Duration + Repetitions int } diff --git a/pkg/database/profile.go b/pkg/database/profile.go index 036073a6..6f3fe749 100644 --- a/pkg/database/profile.go +++ b/pkg/database/profile.go @@ -27,6 +27,7 @@ type UserPreferredUnits struct { SpeedRaw string `form:"speed" json:"speed"` // The user's preferred speed unit DistanceRaw string `form:"distance" json:"distance"` // The user's preferred distance unit ElevationRaw string `form:"elevation" json:"elevation"` // The user's preferred elevation unit + WeightRaw string `form:"weight" json:"weight"` // The user's preferred weight unit } func (u UserPreferredUnits) Tempo() string { @@ -50,6 +51,15 @@ func (u UserPreferredUnits) Elevation() string { } } +func (u UserPreferredUnits) Weight() string { + switch u.WeightRaw { + case "lbs": + return "lbs" + default: + return "kg" + } +} + func (u UserPreferredUnits) Distance() string { switch u.DistanceRaw { case "mi": diff --git a/pkg/database/workout_type.go b/pkg/database/workout_type.go index 60937d58..9462e7e5 100644 --- a/pkg/database/workout_type.go +++ b/pkg/database/workout_type.go @@ -8,28 +8,84 @@ const ( // We need to add each of these types to the "messages.html" partial view. // Then it gets picked up by the i18n system, added to the list of translatable // strings, etc. - WorkoutTypeAutoDetect WorkoutType = "auto" - WorkoutTypeRunning WorkoutType = "running" - WorkoutTypeCycling WorkoutType = "cycling" - WorkoutTypeWalking WorkoutType = "walking" - WorkoutTypeSkiing WorkoutType = "skiing" - WorkoutTypeSnowboarding WorkoutType = "snowboarding" - WorkoutTypeSwimming WorkoutType = "swimming" - WorkoutTypeKayaking WorkoutType = "kayaking" - WorkoutTypeGolfing WorkoutType = "golfing" - WorkoutTypeHiking WorkoutType = "hiking" + WorkoutTypeAutoDetect WorkoutType = "auto" + WorkoutTypeRunning WorkoutType = "running" + WorkoutTypeCycling WorkoutType = "cycling" + WorkoutTypeWalking WorkoutType = "walking" + WorkoutTypeSkiing WorkoutType = "skiing" + WorkoutTypeSnowboarding WorkoutType = "snowboarding" + WorkoutTypeSwimming WorkoutType = "swimming" + WorkoutTypeKayaking WorkoutType = "kayaking" + WorkoutTypeGolfing WorkoutType = "golfing" + WorkoutTypeHiking WorkoutType = "hiking" + WorkoutTypePushups WorkoutType = "push-ups" + WorkoutTypeWeightLifting WorkoutType = "weight lifting" ) -func WorkoutTypes() []WorkoutType { - return []WorkoutType{WorkoutTypeRunning, WorkoutTypeCycling, WorkoutTypeWalking, WorkoutTypeSkiing, WorkoutTypeSnowboarding, WorkoutTypeSwimming, WorkoutTypeKayaking, WorkoutTypeGolfing, WorkoutTypeHiking} +type WorkoutTypeConfiguration struct { + Location bool + Distance bool + Repetition bool + Weight bool +} + +var workoutTypeConfigs = map[WorkoutType]WorkoutTypeConfiguration{ + WorkoutTypeRunning: {Location: true, Distance: true, Repetition: false, Weight: false}, + WorkoutTypeCycling: {Location: true, Distance: true, Repetition: false, Weight: false}, + WorkoutTypeWalking: {Location: true, Distance: true, Repetition: false, Weight: false}, + WorkoutTypeSkiing: {Location: true, Distance: true, Repetition: false, Weight: false}, + WorkoutTypeSnowboarding: {Location: true, Distance: true, Repetition: false, Weight: false}, + WorkoutTypeSwimming: {Location: true, Distance: true, Repetition: false, Weight: false}, + WorkoutTypeKayaking: {Location: true, Distance: true, Repetition: false, Weight: false}, + WorkoutTypeGolfing: {Location: true, Distance: true, Repetition: false, Weight: false}, + WorkoutTypeHiking: {Location: true, Distance: true, Repetition: false, Weight: false}, + + WorkoutTypePushups: {Location: false, Distance: false, Repetition: true, Weight: false}, + WorkoutTypeWeightLifting: {Location: false, Distance: false, Repetition: true, Weight: true}, } -func DurationWorkoutTypes() []WorkoutType { - return []WorkoutType{WorkoutTypeRunning, WorkoutTypeCycling, WorkoutTypeWalking, WorkoutTypeSkiing, WorkoutTypeSnowboarding, WorkoutTypeSwimming, WorkoutTypeKayaking, WorkoutTypeGolfing, WorkoutTypeHiking} +func WorkoutTypes() []WorkoutType { + keys := []WorkoutType{} + + for k := range workoutTypeConfigs { + keys = append(keys, k) + } + + slices.Sort(keys) + + return keys } func DistanceWorkoutTypes() []WorkoutType { - return []WorkoutType{WorkoutTypeRunning, WorkoutTypeCycling, WorkoutTypeWalking, WorkoutTypeSkiing, WorkoutTypeSnowboarding, WorkoutTypeSwimming, WorkoutTypeKayaking, WorkoutTypeGolfing, WorkoutTypeHiking} + keys := []WorkoutType{} + + for k, c := range workoutTypeConfigs { + if !c.Distance { + continue + } + + keys = append(keys, k) + } + + slices.Sort(keys) + + return keys +} + +func LocationWorkoutTypes() []WorkoutType { + keys := []WorkoutType{} + + for k, c := range workoutTypeConfigs { + if !c.Location { + continue + } + + keys = append(keys, k) + } + + slices.Sort(keys) + + return keys } func (wt WorkoutType) String() string { @@ -37,11 +93,24 @@ func (wt WorkoutType) String() string { } func (wt WorkoutType) IsDistance() bool { - return slices.Contains(DistanceWorkoutTypes(), wt) + return workoutTypeConfigs[wt].Distance +} + +func (wt WorkoutType) IsRepetition() bool { + return workoutTypeConfigs[wt].Repetition } func (wt WorkoutType) IsDuration() bool { - return slices.Contains(DurationWorkoutTypes(), wt) + _, ok := workoutTypeConfigs[wt] + return ok +} + +func (wt WorkoutType) IsWeight() bool { + return workoutTypeConfigs[wt].Weight +} + +func (wt WorkoutType) IsLocation() bool { + return workoutTypeConfigs[wt].Location } func AsWorkoutType(s string) WorkoutType { diff --git a/pkg/database/workouts.go b/pkg/database/workouts.go index 4c770c8a..e3a6b108 100644 --- a/pkg/database/workouts.go +++ b/pkg/database/workouts.go @@ -46,6 +46,14 @@ type GPXData struct { Filename string // The filename of the file } +func (w *Workout) Repetitions() int { + if w.Data == nil { + return 0 + } + + return w.Data.TotalRepetitions +} + func (w *Workout) Duration() time.Duration { if w.Data == nil { return 0 diff --git a/pkg/database/workouts_map.go b/pkg/database/workouts_map.go index da4431b4..6f9dec43 100644 --- a/pkg/database/workouts_map.go +++ b/pkg/database/workouts_map.go @@ -54,20 +54,22 @@ func correctAltitude(creator string, lat, long, alt float64) float64 { type MapData struct { gorm.Model - WorkoutID uint `gorm:"not null;uniqueIndex"` // The workout this data belongs to - Creator string // The tool that created this workout - Name string // The name of the workout - Center MapCenter `gorm:"serializer:json"` // The center of the workout (in coordinates) - Address *geo.Address `gorm:"serializer:json"` // The address of the workout - TotalDistance float64 // The total distance of the workout - TotalDuration time.Duration // The total duration of the workout - MaxSpeed float64 // The maximum speed of the workout - PauseDuration time.Duration // The total pause duration of the workout - MinElevation float64 // The minimum elevation of the workout - MaxElevation float64 // The maximum elevation of the workout - TotalUp float64 // The total distance up of the workout - TotalDown float64 // The total distance down of the workout - Details *MapDataDetails `json:",omitempty"` // The details of the workout + WorkoutID uint `gorm:"not null;uniqueIndex"` // The workout this data belongs to + Creator string // The tool that created this workout + Name string // The name of the workout + Center MapCenter `gorm:"serializer:json"` // The center of the workout (in coordinates) + Address *geo.Address `gorm:"serializer:json"` // The address of the workout + TotalDistance float64 // The total distance of the workout + TotalDuration time.Duration // The total duration of the workout + MaxSpeed float64 // The maximum speed of the workout + PauseDuration time.Duration // The total pause duration of the workout + MinElevation float64 // The minimum elevation of the workout + MaxElevation float64 // The maximum elevation of the workout + TotalUp float64 // The total distance up of the workout + TotalDown float64 // The total distance down of the workout + Details *MapDataDetails `json:",omitempty"` // The details of the workout + TotalRepetitions int // The number of repetitions of the workout + TotalWeight float64 // The weight of the workout Points []MapPoint `gorm:"serializer:json" json:"-"` // To be removed } diff --git a/views/equipment/equipment_show.html b/views/equipment/equipment_show.html index 06afad04..3c3711e3 100644 --- a/views/equipment/equipment_show.html +++ b/views/equipment/equipment_show.html @@ -38,6 +38,11 @@

{{ i18n "Total duration" }} {{ .Duration | HumanDuration }} + + + {{ i18n "Total repetitions" }} + {{ .Repetitions }} + {{ end }} diff --git a/views/partials/user_profile_preferred_units.html b/views/partials/user_profile_preferred_units.html index d2e02b55..6b6f3978 100644 --- a/views/partials/user_profile_preferred_units.html +++ b/views/partials/user_profile_preferred_units.html @@ -51,6 +51,21 @@ + + + + + + + + diff --git a/views/partials/workout_details.html b/views/partials/workout_details.html index f71d6bd1..0dc7ea3b 100644 --- a/views/partials/workout_details.html +++ b/views/partials/workout_details.html @@ -23,6 +23,19 @@ {{ i18n .Type.String }} + {{ if .Type.IsRepetition }} + + + {{ i18n "Repetitions" }} + {{ .Data.TotalRepetitions }} + + {{ end }}{{ if .Type.IsWeight }} + + + {{ i18n "Weight" }} + {{ .Data.TotalWeight }} + + {{ end }} {{ if .Type.IsDuration }} {{ i18n "Total duration" }} @@ -37,6 +50,7 @@ {{ .Data.PauseDuration | HumanDuration }} + {{ end }} {{ if .Type.IsDistance }} {{ i18n "Total distance" }} @@ -45,6 +59,7 @@ CurrentUser.PreferredUnits.Distance }} + {{ end }} {{ if and .Type.IsDistance .Type.IsDuration }} {{ i18n "Average speed" }} @@ -84,6 +99,7 @@ {{ .Data.MaxSpeed | HumanSpeed }} {{ CurrentUser.PreferredUnits.Speed }} + {{ end }} {{ if .Type.IsLocation }} {{ i18n "Min elevation" }} @@ -116,6 +132,7 @@ CurrentUser.PreferredUnits.Elevation }} + {{ end }} {{ i18n "Equipment" }} diff --git a/views/workouts/workout_form.html b/views/workouts/workout_form.html new file mode 100644 index 00000000..b7bb49c7 --- /dev/null +++ b/views/workouts/workout_form.html @@ -0,0 +1,59 @@ + + + + + + + + + + + +{{ if .IsDuration }} + + + + +{{ end }} {{ if .IsDistance }} + + + + + {{ CurrentUser.PreferredUnits.Distance }} + + +{{ end }} {{ if .IsRepetition }} + + + + +{{ end }} {{ if .IsWeight }} + + + + + {{ CurrentUser.PreferredUnits.Weight }} + + +{{ end }} + + + + + + + + diff --git a/views/workouts/workouts_add.html b/views/workouts/workouts_add.html index 93424e7c..4613f1ce 100644 --- a/views/workouts/workouts_add.html +++ b/views/workouts/workouts_add.html @@ -1,66 +1,121 @@ {{ template "head" }} + {{ template "header" . }}
-
-

{{ i18n "Add a workout" }}

-
- - - - - - - - - - - - - - - - - - - -
- -
- - - -
- - - -
- -
-
+

{{ i18n "Add a workout" }}

+
+
+
+

{{ i18n "Use a file" }}

+
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ + + +
+ +
+
+
+
+
+
+

{{ i18n "Manual" }}

+
+ + + + + + + + + + + + + + + + + + + +
+ + + +
Select a category
+ +
+
+
+
diff --git a/views/workouts/workouts_show.html b/views/workouts/workouts_show.html index 0dfd032d..2c6b1f65 100644 --- a/views/workouts/workouts_show.html +++ b/views/workouts/workouts_show.html @@ -33,6 +33,7 @@

+ {{ if .Type.IsLocation }}
.}} {{ end }}
+ {{ end }}
{{ template "workout_details" . }}
- {{ if .Data.Details }} + {{ if and .Type.IsDistance .Type.IsDuration .Data.Details }}
{{ template "workout_breakdown" (.StatisticsPer 1