Initial commit.
This commit is contained in:
commit
cc0ec72936
303
cmd/lb/main.go
Normal file
303
cmd/lb/main.go
Normal file
|
@ -0,0 +1,303 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wow.st/gmp/logbook"
|
||||
"git.wow.st/gmp/logbook/parser"
|
||||
"git.wow.st/gmp/logbook/review"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
func expandPath(path string) string {
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
return filepath.Join(home, path[2:])
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, `Usage:
|
||||
lb [flags] <command> [args]
|
||||
|
||||
Commands:
|
||||
import [file] Import workouts from DSL file (or stdin if no file)
|
||||
list List all workouts in the database
|
||||
list-measurements List all measurements in the database
|
||||
performances [exercise] [variation] List past performances on specified exercise
|
||||
predict date variable Predict the value of variable on date
|
||||
delete [all][workouts][measurements] delete specified items
|
||||
|
||||
Flags:
|
||||
-db <path> Path to BoltDB database file (default: ~/.config/logbook/logbook.db)
|
||||
`)
|
||||
}
|
||||
|
||||
func getPerformances(wr *logbook.WorkoutRepository, mr *logbook.MeasurementRepository) ([]review.Performance, error) {
|
||||
pr := review.NewPerformanceRepository(wr, mr)
|
||||
filter := func(p review.Performance) bool { return true }
|
||||
switch flag.NArg() {
|
||||
case 1:
|
||||
case 2:
|
||||
filter = func(p review.Performance) bool {
|
||||
if p.Exercise == flag.Arg(1) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
case 3:
|
||||
filter = func(p review.Performance) bool {
|
||||
if p.Exercise == flag.Arg(1) && p.Type == flag.Arg(2) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
default:
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ps, err := pr.FindPerformances(filter)
|
||||
return ps, err
|
||||
}
|
||||
func main() {
|
||||
defaultDB := "~/.config/logbook/logbook.db"
|
||||
var dbPath string
|
||||
flag.StringVar(&dbPath, "db", defaultDB, "Path to BoltDB database file")
|
||||
flag.Usage = usage
|
||||
flag.Parse()
|
||||
|
||||
if flag.NArg() == 0 {
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dbPath = expandPath(dbPath)
|
||||
|
||||
db, err := bolt.Open(dbPath, 0600, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
wr, err := logbook.NewWorkoutRepository(db)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error opening workout repository: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
mr, err := logbook.NewMeasurementRepository(db)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error opening measurement repository: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cmd := flag.Arg(0)
|
||||
switch cmd {
|
||||
case "import":
|
||||
var input io.Reader
|
||||
if flag.NArg() > 1 {
|
||||
f, err := os.Open(flag.Arg(1))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error opening file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
input = f
|
||||
} else {
|
||||
input = os.Stdin
|
||||
}
|
||||
|
||||
dslBytes, err := io.ReadAll(input)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
dslText := string(dslBytes)
|
||||
|
||||
workouts, measurements, err := parser.ParseDSL(dslText)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Parse error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := wr.AddWorkoutsFromIR(workouts); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Database write error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := mr.AddMeasurementsFromIR(measurements); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Database write error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully imported %d workout(s) and %d measurement(s).\n", len(workouts), len(measurements))
|
||||
|
||||
case "list":
|
||||
workouts, _, err := wr.FindWorkouts(func(w *parser.Workout) bool { return true })
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading workouts: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(workouts) == 0 {
|
||||
fmt.Println("No workouts found.")
|
||||
return
|
||||
}
|
||||
|
||||
for _, w := range workouts {
|
||||
dateStr := w.Date.Format("2 Jan 2006")
|
||||
fmt.Printf("Workout: %s (%s) %d SetGroups.", dateStr, w.Type, len(w.SetGroups))
|
||||
if w.Note != "" {
|
||||
fmt.Printf(" \"%s\"", w.Note)
|
||||
}
|
||||
fmt.Println()
|
||||
for _, sg := range w.SetGroups {
|
||||
fmt.Printf("Setgroup: %s", sg.Exercise)
|
||||
if sg.Type != "" {
|
||||
fmt.Printf(" (%s)", sg.Type)
|
||||
}
|
||||
fmt.Printf(" %d planned sets.", len(sg.PlannedSets))
|
||||
if sg.Note != "" {
|
||||
fmt.Printf(" \"%s\"", sg.Note)
|
||||
}
|
||||
fmt.Println()
|
||||
for _, s := range sg.PlannedSets {
|
||||
fmt.Printf("Planned Set: %.0fx%d", s.Weight, s.Reps)
|
||||
if s.Note != "" {
|
||||
fmt.Printf(" (%s)", s.Note)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
if len(sg.ActualSets) > 0 {
|
||||
for _, s := range sg.ActualSets {
|
||||
fmt.Printf("Actual Set: %.0fx%d (%d RIR)", s.Weight, s.Reps, s.RIR)
|
||||
if s.Note != "" {
|
||||
fmt.Printf(" (%s)", s.Note)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "list-measurements":
|
||||
ms, err := mr.FindAll()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error finding measurements: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _,m := range(ms) {
|
||||
dateStr := m.Date.Format("2 Jan 2006")
|
||||
fmt.Printf("Measurement: %s %s = %0.1f\n", dateStr, m.Variable, m.Value)
|
||||
}
|
||||
case "performances":
|
||||
ps, err := getPerformances(wr, mr)
|
||||
if err != nil {
|
||||
fmt.Printf("Error finding performance: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, p := range (ps) {
|
||||
fmt.Printf("Performance: %v\n", p)
|
||||
}
|
||||
case "estimate": // show rep chart for an exercise
|
||||
ps, err := getPerformances(wr, mr)
|
||||
if err != nil {
|
||||
fmt.Printf("Error finding performance: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var weights, reps []float64
|
||||
var date []time.Time
|
||||
isbw := review.IsBodyweight(ps[0].Exercise, ps[0].Type)
|
||||
|
||||
for _, p := range (ps) {
|
||||
for i, w := range (p.Weights) {
|
||||
if isbw {
|
||||
w = w + p.Bodyweight
|
||||
}
|
||||
if p.RIR[i] == 0 {
|
||||
weights = append(weights, w)
|
||||
reps = append(reps, float64(p.Reps[i]))
|
||||
date = append(date, p.Times[i].UTC())
|
||||
fmt.Printf("Set: weight %0.0f, reps %0.0f\n", w, float64(p.Reps[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
a, b := review.FitPowerLaw(weights, reps, date, 14.0)
|
||||
for i := 1; i<=10; i++ {
|
||||
adj := 0.0
|
||||
if isbw {
|
||||
adj = ps[len(ps)-1].Bodyweight
|
||||
}
|
||||
fmt.Printf("%d: %0.0f\n", i, review.EstimateMaxWeight(a, b, float64(i)) - adj)
|
||||
}
|
||||
case "predict":
|
||||
if flag.NArg() != 3 {
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
dateStr := flag.Arg(1)
|
||||
date, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
fmt.Printf("Invalid date: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
variable := flag.Arg(2)
|
||||
fmt.Printf("Predicting %s for date %s\n", variable, date.Format("2006-01-02"))
|
||||
|
||||
p, err := mr.Predict(variable, date, 0.75)
|
||||
if err != nil {
|
||||
fmt.Printf("Prediction error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("%f\n",p)
|
||||
case "delete":
|
||||
if flag.NArg() != 2 {
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
switch flag.Arg(1) {
|
||||
case "workouts":
|
||||
_, ids, err := wr.FindWorkouts(func(*parser.Workout) bool { return true })
|
||||
if err != nil {
|
||||
fmt.Printf("Error finding workouts: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, id := range(ids) {
|
||||
err = wr.DeleteWorkout(id)
|
||||
if err != nil {
|
||||
fmt.Printf("Error deleting workout: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
fmt.Printf("%d workouts deleted.\n", len(ids))
|
||||
case "measurements":
|
||||
ms, err := mr.FindAll()
|
||||
if err != nil {
|
||||
fmt.Printf("Error finding measurements: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, m := range(ms) {
|
||||
err = mr.Delete(m.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("Error deleting workout: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
fmt.Printf("%d measurements deleted.\n", len(ms))
|
||||
}
|
||||
default:
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
126
cmd/lb/weight.txt
Normal file
126
cmd/lb/weight.txt
Normal file
|
@ -0,0 +1,126 @@
|
|||
2025-05-05 measurement bodyweight=170.4
|
||||
2025-05-02 measurement bodyweight=170.5
|
||||
2025-04-30 measurement bodyweight=172
|
||||
2025-04-28 measurement bodyweight=173.8
|
||||
2025-04-25 measurement bodyweight=170.2
|
||||
2025-04-23 measurement bodyweight=174.8
|
||||
2025-04-21 measurement bodyweight=174.5
|
||||
2025-04-16 measurement bodyweight=170.5
|
||||
2025-04-14 measurement bodyweight=172
|
||||
2025-04-11 measurement bodyweight=173
|
||||
2025-04-09 measurement bodyweight=173
|
||||
2025-04-07 measurement bodyweight=175
|
||||
2025-04-04 measurement bodyweight=174
|
||||
2025-04-02 measurement bodyweight=173
|
||||
2025-04-01 measurement bodyweight=173.5
|
||||
2025-03-27 measurement bodyweight=171
|
||||
2025-03-26 measurement bodyweight=174
|
||||
2025-03-24 measurement bodyweight=175
|
||||
2025-03-21 measurement bodyweight=174
|
||||
2025-03-19 measurement bodyweight=172
|
||||
2025-03-17 measurement bodyweight=176
|
||||
2025-03-14 measurement bodyweight=175
|
||||
2025-03-12 measurement bodyweight=175
|
||||
2025-03-10 measurement bodyweight=177
|
||||
2025-03-07 measurement bodyweight=174
|
||||
2025-03-05 measurement bodyweight=173
|
||||
2025-03-04 measurement bodyweight=175
|
||||
2025-02-28 measurement bodyweight=178
|
||||
2025-02-26 measurement bodyweight=176
|
||||
2025-02-24 measurement bodyweight=181
|
||||
2025-02-20 measurement bodyweight=176
|
||||
2025-02-14 measurement bodyweight=177
|
||||
2025-02-12 measurement bodyweight=176
|
||||
2025-02-10 measurement bodyweight=179
|
||||
2025-02-07 measurement bodyweight=178
|
||||
2025-02-05 measurement bodyweight=179
|
||||
2025-02-03 measurement bodyweight=178
|
||||
2025-01-31 measurement bodyweight=179
|
||||
2025-01-29 measurement bodyweight=178
|
||||
2025-01-27 measurement bodyweight=177
|
||||
2025-01-24 measurement bodyweight=179
|
||||
2025-01-22 measurement bodyweight=179
|
||||
2025-01-19 measurement bodyweight=176.5
|
||||
2025-01-17 measurement bodyweight=175.5
|
||||
2025-01-15 measurement bodyweight=178
|
||||
2025-01-13 measurement bodyweight=177
|
||||
2025-01-10 measurement bodyweight=176
|
||||
2025-01-08 measurement bodyweight=175.5
|
||||
2025-01-06 measurement bodyweight=177
|
||||
2025-01-03 measurement bodyweight=174
|
||||
2025-01-01 measurement bodyweight=176
|
||||
2024-12-23 measurement bodyweight=175
|
||||
2024-12-20 measurement bodyweight=173
|
||||
2024-12-18 measurement bodyweight=173
|
||||
2024-12-17 measurement bodyweight=174
|
||||
2024-12-16 measurement bodyweight=173
|
||||
2024-12-13 measurement bodyweight=171.5
|
||||
2024-12-11 measurement bodyweight=172.5
|
||||
2024-12-09 measurement bodyweight=173
|
||||
2024-12-06 measurement bodyweight=172
|
||||
2024-12-04 measurement bodyweight=171
|
||||
2024-11-22 measurement bodyweight=174
|
||||
2024-11-20 measurement bodyweight=173.5
|
||||
2024-11-18 measurement bodyweight=174
|
||||
2024-11-16 measurement bodyweight=173
|
||||
2024-11-12 measurement bodyweight=173.5
|
||||
2024-11-09 measurement bodyweight=175
|
||||
2024-11-04 measurement bodyweight=175
|
||||
2024-11-01 measurement bodyweight=174
|
||||
2024-10-30 measurement bodyweight=173.5
|
||||
2024-10-28 measurement bodyweight=175
|
||||
2024-10-25 measurement bodyweight=174
|
||||
2024-10-23 measurement bodyweight=174
|
||||
2024-10-21 measurement bodyweight=173
|
||||
2024-10-18 measurement bodyweight=172
|
||||
2024-10-15 measurement bodyweight=173
|
||||
2024-10-11 measurement bodyweight=173
|
||||
2024-10-09 measurement bodyweight=170
|
||||
2024-10-07 measurement bodyweight=171
|
||||
2024-10-02 measurement bodyweight=169.5
|
||||
2024-09-30 measurement bodyweight=171
|
||||
2024-09-27 measurement bodyweight=170.5
|
||||
2024-09-25 measurement bodyweight=169
|
||||
2024-09-23 measurement bodyweight=169
|
||||
2024-09-20 measurement bodyweight=169
|
||||
2024-09-18 measurement bodyweight=170
|
||||
2024-09-16 measurement bodyweight=172
|
||||
2024-09-13 measurement bodyweight=168
|
||||
2024-09-09 measurement bodyweight=169
|
||||
2024-09-06 measurement bodyweight=170
|
||||
2024-09-03 measurement bodyweight=170
|
||||
2024-09-02 measurement bodyweight=170
|
||||
2024-08-23 measurement bodyweight=170
|
||||
2024-08-21 measurement bodyweight=169
|
||||
2024-08-19 measurement bodyweight=170
|
||||
2024-08-16 measurement bodyweight=169
|
||||
2024-08-13 measurement bodyweight=168.5
|
||||
2024-08-09 measurement bodyweight=169
|
||||
2024-08-07 measurement bodyweight=170
|
||||
2024-08-05 measurement bodyweight=169
|
||||
2024-08-02 measurement bodyweight=167
|
||||
2024-07-31 measurement bodyweight=169
|
||||
2024-07-29 measurement bodyweight=167
|
||||
2024-07-25 measurement bodyweight=165
|
||||
2024-07-12 measurement bodyweight=168
|
||||
2024-07-08 measurement bodyweight=166
|
||||
2024-07-01 measurement bodyweight=168
|
||||
2024-06-28 measurement bodyweight=166
|
||||
2024-06-26 measurement bodyweight=167
|
||||
2024-06-24 measurement bodyweight=167
|
||||
2024-06-21 measurement bodyweight=165
|
||||
2024-06-18 measurement bodyweight=165
|
||||
2024-06-14 measurement bodyweight=167
|
||||
2024-06-12 measurement bodyweight=166
|
||||
2024-06-05 measurement bodyweight=166
|
||||
2024-06-03 measurement bodyweight=166
|
||||
2024-05-31 measurement bodyweight=169
|
||||
2024-05-13 measurement bodyweight=166
|
||||
2024-05-10 measurement bodyweight=164
|
||||
2024-05-06 measurement bodyweight=165
|
||||
2024-05-03 measurement bodyweight=164
|
||||
2024-05-01 measurement bodyweight=164
|
||||
2024-04-29 measurement bodyweight=164
|
||||
2024-04-26 measurement bodyweight=163
|
||||
2024-04-16 measurement bodyweight=164
|
||||
2024-03-16 measurement bodyweight=162
|
684
cmd/lb/workouts.txt
Normal file
684
cmd/lb/workouts.txt
Normal file
|
@ -0,0 +1,684 @@
|
|||
2025-05-09 workout
|
||||
dip 110 1x8 (AMRAP)
|
||||
8 max failed rep 9
|
||||
pushdown 65 2x12
|
||||
13-5 failed 6
|
||||
dip 70 3x8
|
||||
10 easy
|
||||
10 last rep a little hard at the top
|
||||
7 failed 8 trying to lock out each rep all the way
|
||||
chinup 75 1x5 or 6
|
||||
5 max failed rep 6 very close to the bar
|
||||
chinup 50 3x8
|
||||
8-7 failed rep 8 near the top
|
||||
6
|
||||
|
||||
2025-05-07 workout
|
||||
hack squat 90 2x10 (ATG)
|
||||
70x3-70x3-70x3
|
||||
hamstring curl 80 2x12 (single leg)
|
||||
12 max
|
||||
9
|
||||
cable crunch 150 2x12
|
||||
20 not to failure
|
||||
20
|
||||
|
||||
2025-05-05 workout (pull)
|
||||
rack pull 225 2x3
|
||||
3-3
|
||||
pullup 25 3x8
|
||||
8-8-8
|
||||
hack squat 90 2x10
|
||||
10-10
|
||||
cable crunch 150 2x10
|
||||
12-12
|
||||
hamstring curl 80 2x10 (single leg)
|
||||
12 max
|
||||
9
|
||||
|
||||
2025-05-02 workout (dip-chin)
|
||||
dip 100 1x1 AMRAP
|
||||
10 max. Failed rep 11 close to lockout
|
||||
pushdown 65 2x12
|
||||
11 max
|
||||
7
|
||||
dip 70 2x10
|
||||
8-10
|
||||
chinup 65 1x1 AMRAP
|
||||
7 max
|
||||
chinup 45 3x8
|
||||
8-8-8
|
||||
chest-supported row 135 2x15
|
||||
15-12
|
||||
|
||||
2025-04-30 workout (legs)
|
||||
hack squat 90 2x10 (ATG)
|
||||
10-10
|
||||
supine leg extension 55 2x15
|
||||
15-15
|
||||
cable crunch 140 2x15
|
||||
18-16
|
||||
hamstring curls 150 2x15
|
||||
18 max partial reps 19 and 20
|
||||
15 partial reps 16 and 17
|
||||
|
||||
2025-04-28 workout (hinge)
|
||||
deadlift 275 1x3
|
||||
3
|
||||
pullup 25 3x8
|
||||
8 easy, good control and ROM
|
||||
8 easy again
|
||||
8 hard but got chin over the bar
|
||||
chest-supported row 115 2x15
|
||||
15-15
|
||||
hack squat 110 2x0
|
||||
10-10
|
||||
hamstring curls 150 2x15
|
||||
17 max partial 18
|
||||
19 max
|
||||
cable crunch 130 2x16
|
||||
20 max
|
||||
16 partial 17
|
||||
|
||||
2025-04-25 workout (dip-chin)
|
||||
dip 90 1x1 AMRAP
|
||||
13 max
|
||||
chinup 70 1x5 AMRAP
|
||||
6 max
|
||||
chinup 45 3x8
|
||||
8-8-8
|
||||
pushdown 65 2x12
|
||||
10 max partial 11
|
||||
4 partial 5
|
||||
dip 45 1x10
|
||||
10 easy (after pushdowns)
|
||||
hack squat 110 2x10
|
||||
10-10
|
||||
supine leg extension 55 2x15
|
||||
12-15
|
||||
|
||||
2025-04-23 workout (hinge)
|
||||
deadlift 315 2x1
|
||||
1 very hard but good form
|
||||
1 grind
|
||||
RDL 225 2x8
|
||||
8 hard but not to failure
|
||||
8 hard but ok, good ROM
|
||||
hack squat 70 3x5
|
||||
5-5-5
|
||||
cable crunch 130 2x15
|
||||
20 max
|
||||
15 partial 16
|
||||
|
||||
2025-04-21 workout
|
||||
dip 150 1x1
|
||||
1 max
|
||||
dip 115 2x3
|
||||
3-3 max
|
||||
pullup 20 3x8
|
||||
8-8-8
|
||||
|
||||
2025-04-16 workout (legs)
|
||||
hack squat 105 2x10
|
||||
10-10
|
||||
cable crunch 130 2x15
|
||||
15-15
|
||||
hamstring curl 150 2x15
|
||||
17 max
|
||||
12 partial reps 13 and 14
|
||||
calf raise 270 1x40
|
||||
40
|
||||
|
||||
2025-04-14 workout (deload)
|
||||
pushdown 65 2x12
|
||||
11 max partial 12
|
||||
11 max partial 12
|
||||
cable crunch 120 2x20
|
||||
20-20
|
||||
supine leg extension 50 2x15
|
||||
20-20
|
||||
|
||||
2025-04-11 workout (dip-chin)
|
||||
dip 50 3x5
|
||||
5-5-5
|
||||
chinup 110 1x1
|
||||
1 max
|
||||
chinup 90 2x3
|
||||
3 max
|
||||
hack squat 100 2x10
|
||||
10-10
|
||||
hamstring curl 150 1x15
|
||||
14 max partial 15
|
||||
supine leg extension 50 2x15
|
||||
15
|
||||
|
||||
2025-04-09 workout (legs)
|
||||
seated leg extension 160 2x15
|
||||
20
|
||||
supine leg extension 50 1x15
|
||||
15
|
||||
reverse hyper 180 2x15
|
||||
15-15
|
||||
calf raise 270 3x30
|
||||
30-30-30
|
||||
|
||||
2025-04-07 workout
|
||||
pullup 20 3x8
|
||||
8-8-8
|
||||
hack squat 100 2x10
|
||||
10-10
|
||||
hamstring curl 140 2x15
|
||||
19 max partial 20
|
||||
13 at failure
|
||||
|
||||
2025-04-04 workout
|
||||
dip 140 1x1
|
||||
1 max
|
||||
dip 115 2x3
|
||||
3 max
|
||||
110x4 max
|
||||
chinup 105 1x1
|
||||
1 max
|
||||
chinup 90 2x3
|
||||
2 chin touched the bar but not over
|
||||
3 max
|
||||
chinup 50 3x8
|
||||
8-8 max
|
||||
6 partial 7
|
||||
hack squat 100 2x10
|
||||
10-10
|
||||
hamstring curl 140 2x15
|
||||
19 max partial 20
|
||||
16 failure partial reps 17, 18 and 19
|
||||
seated leg extension 150 2x20
|
||||
20-20
|
||||
|
||||
2025-04-02 workout stairmaster warmup, 12 minutes at level 5
|
||||
seated leg extension 150 2x15
|
||||
20-20
|
||||
hamstring curl 130 2x15
|
||||
20 max partial 21
|
||||
19 failure partial 20
|
||||
cable crunch 120 2x16
|
||||
16-16
|
||||
prayer pulldown 70 2x12
|
||||
12-12
|
||||
|
||||
2025-04-01 workout (pull)
|
||||
deadlift 275 2x1
|
||||
1 felt good but a little slow
|
||||
1 a little bit of a grind, back is ok but feels harder than it should
|
||||
RDL 225 1x5
|
||||
5
|
||||
RDL 135 1x10 (deficit)
|
||||
10
|
||||
pullup 20 2x8
|
||||
8 max
|
||||
7 failure partial 8
|
||||
hack squat 100 2x10
|
||||
10-10
|
||||
pushdown 65 2x12
|
||||
13 max partial 14
|
||||
10 at failure
|
||||
|
||||
2025-03-27 workout
|
||||
dip 135 1x1
|
||||
1 max
|
||||
dip 115 2x3
|
||||
3-3 max
|
||||
chinup 115 1x1
|
||||
0
|
||||
chinup 90 2x3
|
||||
3-3 max
|
||||
chinup 45 3x8
|
||||
8-8-9 max
|
||||
hack squat 95 2x10
|
||||
10-10
|
||||
hamstring curl 130 2x15
|
||||
18
|
||||
12 partial reps 13 and 14
|
||||
seated leg extension 150 2x15
|
||||
15-15
|
||||
cable crunch 110 2x15
|
||||
15-15
|
||||
|
||||
2025-03-26 workout
|
||||
jefferson curl 90 2x5 on blue plate
|
||||
5-5
|
||||
suitcase deadlift 90 1x5
|
||||
5
|
||||
barbell side bend 90 1x5
|
||||
5
|
||||
cable crunch 100 2x20
|
||||
15-20
|
||||
hack squat 90 2x10
|
||||
10-10
|
||||
hamstring curl 130 2x15
|
||||
20 max
|
||||
14 failure partial 15
|
||||
seated leg extension 140 2x20
|
||||
20-20
|
||||
|
||||
2025-03-24 workout
|
||||
chinup 25 3x9
|
||||
10-10 max
|
||||
hack squat 85 2x10
|
||||
10-10
|
||||
dumbbell row 120 3x12
|
||||
12-12-12 max
|
||||
|
||||
2025-03-21 workout
|
||||
dip 125 1x1
|
||||
1
|
||||
dip 45 2x10
|
||||
10-10
|
||||
jefferson curl 132 2x10
|
||||
10-10
|
||||
cable crunch 100 1x12
|
||||
12
|
||||
hack squat 80 2x10
|
||||
10-10
|
||||
hamstring curl 130 2x20
|
||||
20 max
|
||||
12 failure partial 13
|
||||
seated leg extension 140 2x15
|
||||
15-15
|
||||
|
||||
2025-03-19 workout
|
||||
jefferson curl 130 2x10
|
||||
10-10
|
||||
hack squat 80 2x10
|
||||
10-10
|
||||
hamstring curl 130 2x18
|
||||
18 max
|
||||
14 failure partial 15
|
||||
cable crunch 80 3x10
|
||||
10-10-10
|
||||
seated leg extension 140 2x15
|
||||
15-15
|
||||
|
||||
2025-03-17 workout
|
||||
jefferson curl 130 1x10
|
||||
10
|
||||
hack squat 70 2x10
|
||||
10-10
|
||||
dumbbell row 120 3x11
|
||||
11-11
|
||||
12 max partial rep 13 on right side, did 20 reps on left side
|
||||
|
||||
2025-03-14 workout
|
||||
dip 45 3x10
|
||||
10-10-10
|
||||
chinup 115 1x1
|
||||
0 missed by barely an inch
|
||||
chinup 90 2x3
|
||||
2 max partial 3
|
||||
2 max partial 3
|
||||
chinup 45 3x8
|
||||
9-9 max
|
||||
7 did not attempt rep 8
|
||||
hack squat 70 1x10
|
||||
10
|
||||
jefferson curl 110 1x10
|
||||
10
|
||||
pushdown 60 2x15
|
||||
16 max
|
||||
5 failure partial 6
|
||||
prayer pulldown 70 1x10
|
||||
9 max partial 10
|
||||
|
||||
2025-03-12 workout warmup stairmaster for 10 minutes at level 6
|
||||
hack squat 70 1x10
|
||||
10
|
||||
jefferson curl 130 1x10 (cable)
|
||||
10
|
||||
jefferson curl 95 1x5
|
||||
5
|
||||
hamstring curl 130 3x18
|
||||
18-18-10
|
||||
seated leg extension 130 3x15
|
||||
15-15-15
|
||||
wrist roller 15 2x1
|
||||
1-1
|
||||
|
||||
2025-03-10 workout
|
||||
chinup 25 3x9
|
||||
10-10 max
|
||||
8 failure partial 9 half way up
|
||||
jefferson curl 120 1x10 (cable)
|
||||
10
|
||||
prayer pulldown 70 3x10
|
||||
10-10-10
|
||||
dumbbell row 120 3x11
|
||||
11-11-11
|
||||
|
||||
2025-03-07 workout
|
||||
dip 45 3x10
|
||||
10-10-10
|
||||
chinup 110 1x1
|
||||
1 max
|
||||
chinup 90 2x2
|
||||
2 max
|
||||
2 close to a third rep
|
||||
chinup 45 3x8
|
||||
8-8-8 max
|
||||
jefferson curl 120 1x10 (cable)
|
||||
10
|
||||
hamstring curl 130 2x17
|
||||
19-17
|
||||
|
||||
2025-03-05 workout warmup stairmaster 10 minutes at level 5
|
||||
decline situp 0 2x10
|
||||
10-7 does not feel good on my low back
|
||||
prayer puldown 70 3x10
|
||||
10-10-10
|
||||
jefferson curl 110 1x10 (cable)
|
||||
10
|
||||
hamstring curl 130 3x18
|
||||
18 max
|
||||
17
|
||||
10 failure partial 11
|
||||
wrist roller 15 2x1
|
||||
1-1
|
||||
|
||||
2025-03-04 workout
|
||||
jefferson curl 70 1x5 (cable)
|
||||
5
|
||||
hack squat 70 1x10
|
||||
10 tibial tuberosity hurts
|
||||
prayer pulldown 60 1x15
|
||||
15
|
||||
|
||||
2025-02-28 workout
|
||||
dip 45 3x10
|
||||
10-10-10
|
||||
chinup 100 1x1
|
||||
1 max
|
||||
chinup 90 2x2
|
||||
2 max
|
||||
2 failure partial 3
|
||||
chinup 45 3x8
|
||||
8-8 max
|
||||
6 failure partial 7 after resetting my grip
|
||||
jefferson curl 100 1x10 (cable)
|
||||
10
|
||||
hack squat 70 2x10
|
||||
10
|
||||
10 left tibial tuberosity hurts a lot
|
||||
pushdown 60 3x15
|
||||
17 max
|
||||
15 failure partial 16
|
||||
13 failure partial 14
|
||||
decline situp 10 2x10
|
||||
9 failure partial 10
|
||||
5 failure partial 6 and did the rest of the set at bodyweight
|
||||
|
||||
2025-02-26 workout
|
||||
jefferson curl 90 1x10 (cable)
|
||||
10
|
||||
hack squat 50 3x10
|
||||
10-10-60x10
|
||||
reverse hyper 90 3x25
|
||||
25-25-25
|
||||
decline situp 0 3x15
|
||||
15
|
||||
15 not hard but feel my low back
|
||||
15 lots of low back pressure
|
||||
hamstring curl 130 3x17
|
||||
17-17
|
||||
15 partial reps 16 and 17
|
||||
|
||||
2025-02-24 workout warmup stairmaster 5 minutes at level 6
|
||||
deadlift 275 3x1
|
||||
1-1-1
|
||||
chinup 25 3x9
|
||||
9-9-9 max
|
||||
hack squat 50 2x5
|
||||
5-5
|
||||
dumbbell row 120 3x10
|
||||
10-10-10 max 12 reps on the left side
|
||||
wrist roller 15 2x1
|
||||
1
|
||||
0 failed 3/4 of the way up and finished with 10 pounds
|
||||
jefferson curl 40 1x10 (cable)
|
||||
10
|
||||
|
||||
2025-02-20 workout
|
||||
dip 100 1x3
|
||||
3 max
|
||||
dip 40 3x10
|
||||
10-10-10
|
||||
chinup 100 1x1
|
||||
0 failed almost all the way up
|
||||
chinup 90 2x2
|
||||
1 max failed partial second rep by 2 inches
|
||||
1 closer but not over the bar on second rep
|
||||
chinup 45 3x8
|
||||
8 max
|
||||
8-6
|
||||
seated leg extension 150 1x18
|
||||
18
|
||||
pushdown 60 3x15
|
||||
16 max
|
||||
14 failure partial 15
|
||||
6 failure partial 7
|
||||
|
||||
2025-02-14 workout
|
||||
dip 40 3x10
|
||||
10-10-10
|
||||
chinup 100 1x1
|
||||
1 max barely over the bar after a grind
|
||||
chinup 90 2x2
|
||||
2 max
|
||||
1
|
||||
chinup 45 3x8
|
||||
7-7-7 max
|
||||
seated leg extension 150 3x18
|
||||
18-18-18
|
||||
pushdown 60 3x15
|
||||
15 max
|
||||
13 failure partial 14
|
||||
11
|
||||
landmine deadlift 115 5x1 (grip)
|
||||
1-1-1-1-1
|
||||
landmine deadlift 90 5x1 (grip)
|
||||
1-1-1-1-1
|
||||
|
||||
2025-02-12 workout warmup stairmaster 10 minutes at level 5
|
||||
seated leg extension 150 3x15
|
||||
17-17-17
|
||||
hamstring curl 130 3x17
|
||||
17-17 max
|
||||
15 failure partial 16
|
||||
tibialis raise 100 3x20
|
||||
20-22-19
|
||||
|
||||
2025-02-10 workout
|
||||
chinup 25 3x9
|
||||
9-9-9 max
|
||||
hamstring curl 130 3x15
|
||||
15-15-15
|
||||
dumbbell row 120 3x9
|
||||
9-9-12 max got 15 on the left side
|
||||
single leg RDL 60 2x5
|
||||
5-5
|
||||
wrist roller 12.5 2x1
|
||||
1-1
|
||||
|
||||
2025-02-07 workout
|
||||
dip 40 3x10
|
||||
10-10-10
|
||||
chinup 90 3x1
|
||||
1-1-1 max
|
||||
chinup 45 3x8
|
||||
7 max
|
||||
7-6
|
||||
pinch grip 50 1x30 (seconds)
|
||||
30
|
||||
pushdown 55 1x19
|
||||
19 max partial rep 20
|
||||
60x9 partial rep 10
|
||||
5
|
||||
seated leg extension 150 3x15
|
||||
18 max
|
||||
18-15
|
||||
|
||||
2025-02-05 workout warmup stairmaster 10 minutes at level 5
|
||||
hamstring curl 130 3x15
|
||||
15-15 max
|
||||
14 partial rep 15
|
||||
supine leg extension 70 2x10
|
||||
10-10
|
||||
seated leg extension 150 3x15
|
||||
15-15-15
|
||||
tibialis raise 100 5x20
|
||||
20-22-105x15-105x4-20
|
||||
|
||||
2025-02-03 workout
|
||||
deadlift 225 3x1
|
||||
1-1-1
|
||||
chinup 25 3x10
|
||||
10 max
|
||||
9
|
||||
7 failure partial 8
|
||||
dumbbell row 120 3x8
|
||||
8-8-8
|
||||
hamstring curl 120 3x15
|
||||
15-15-15
|
||||
wrist roller 12.5 2x1
|
||||
1-1
|
||||
|
||||
2025-01-31 workout
|
||||
dip 35 3x10
|
||||
10-10-10
|
||||
chinup 90 1x1
|
||||
1 max
|
||||
chinup 45 3x6
|
||||
6 max
|
||||
6-7
|
||||
seated leg extension 140 3x20
|
||||
20-20-20
|
||||
pushdown 50 3x15
|
||||
15 max
|
||||
55x11 failure partial 12
|
||||
55x12 max failure partial 13
|
||||
|
||||
2025-01-29 workout
|
||||
RDL 225 3x10
|
||||
10-10-10
|
||||
barbell row 225 1x5
|
||||
5 partial reps, too heavy
|
||||
seated leg extension 140 3x20
|
||||
20-20-20
|
||||
pushdown 40 1x20
|
||||
20
|
||||
|
||||
2025-01-27 workout warmup stairmaster 10 minutes at level 5
|
||||
deadlift 275 3x5
|
||||
5-5-5
|
||||
deadlift 235 8x4 (EMOM)
|
||||
4-4-4-4-4-4-4-4
|
||||
wrist roller 12.5 2x1
|
||||
1-1
|
||||
|
||||
2025-01-24 workout
|
||||
seated leg extension 130 3x20
|
||||
20-20-140x20
|
||||
dip 30 3x10
|
||||
10-10-10
|
||||
chinup 30 3x8
|
||||
8-8-8 max
|
||||
pushdown 40 1x12
|
||||
12-17-45x12
|
||||
tibialis raise 95 4x20
|
||||
20-100x19-100x15-100x20
|
||||
|
||||
2025-01-22 workout warmup stairmaster 1 minutes at level 5
|
||||
seated leg extension 130 2x20
|
||||
20-20
|
||||
barbell row 185 3x8
|
||||
8-8-8
|
||||
dumbbell row 120 4x8
|
||||
8-8-8-8
|
||||
|
||||
2025-01-17 workout warmup stairmaster 10 miinutes at level 5
|
||||
seated leg extension 130 3x20
|
||||
20-20-20
|
||||
dip 30 3x10
|
||||
10-10-10
|
||||
chinup 30 3x8
|
||||
8-8-8
|
||||
|
||||
2025-01-15 workout
|
||||
seated leg extension 130 3x20
|
||||
20-20-140x20
|
||||
barbell row 175 3x8
|
||||
10-9-8
|
||||
dumbbell row 120 3x10
|
||||
12-10-10
|
||||
|
||||
2025-01-13 workout
|
||||
deadlift 275 3x4
|
||||
4-4-4
|
||||
deadlift 235 10x4 (EMOM)
|
||||
4-4-4-4-4-4-4-4-4-4
|
||||
RDL 225 3x8
|
||||
8-8-8
|
||||
triceps overhead 25 3x10
|
||||
10-10-10
|
||||
wrist roller 10 2x1
|
||||
1-12.5x1
|
||||
|
||||
2025-01-10 workout warmup stairmaster 10 minutes at level 5
|
||||
seated leg extension 120 3x20
|
||||
20-20-25
|
||||
dip 30 3x10
|
||||
10-10-10
|
||||
chinup 30 3x8
|
||||
8-8-8 max
|
||||
pushdown 45 3x10
|
||||
10-10
|
||||
9 failure partial 10
|
||||
triceps overhead 35 3x10
|
||||
10-10-10
|
||||
|
||||
2025-01-08 workout warmup stairmaster 10 minutes at level 5
|
||||
seated leg extension 120 3x20
|
||||
20-20-17
|
||||
barbell row 170 3x8
|
||||
8-8-8
|
||||
dumbbell row 105 4x12
|
||||
12-12-13-120x9
|
||||
|
||||
2025-01-06 workout
|
||||
deadlift 275 3x3
|
||||
3-3-3
|
||||
deadlift 235 10x3 (EMOM) deficit
|
||||
3-3-3-3-3-3-3-3-3-3
|
||||
RDL 235 3x8
|
||||
8-8-8
|
||||
triceps overhead 30 3x10
|
||||
10-10-10
|
||||
|
||||
2025-01-03 workout
|
||||
seated leg extension 110 3x20
|
||||
20-20-20
|
||||
dip 30 3x10
|
||||
10-10-10
|
||||
chinup 30 3x8
|
||||
8-8-8
|
||||
tibialis raise 100 4x20
|
||||
20-20-5-20
|
||||
pushdown 45 3x10
|
||||
10-10-10
|
||||
|
||||
2025-01-01 workout warmup stairmaster 10 minutes at level 5
|
||||
supine leg extension 65 4x20 (bilateral)
|
||||
20-20-20-20
|
||||
barebll row 165 3x8
|
||||
8-8-175x8
|
||||
dumbbell row 100 3x12
|
||||
12-12-12
|
||||
tibialis raise 100 4x20
|
||||
20-19-20-16
|
||||
|
403
cmd/logbook/main.go
Normal file
403
cmd/logbook/main.go
Normal file
|
@ -0,0 +1,403 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"log"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
bolt "github.com/etcd-io/bbolt"
|
||||
"git.wow.st/gmp/logbook"
|
||||
)
|
||||
|
||||
var (
|
||||
btnPrevWorkouts widget.Clickable
|
||||
btnPredictStrength widget.Clickable
|
||||
btnMeasurements widget.Clickable
|
||||
btnPlanWorkout widget.Clickable
|
||||
btnLeft widget.Clickable
|
||||
btnRight widget.Clickable
|
||||
list = new(layout.List) // pointer to layout.List
|
||||
)
|
||||
|
||||
type page int
|
||||
|
||||
const (
|
||||
pageLanding page = iota
|
||||
pagePlanWorkout
|
||||
)
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
window := new(app.Window)
|
||||
if err := run(window); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
os.Exit(0)
|
||||
}()
|
||||
app.Main()
|
||||
}
|
||||
|
||||
func openStandardDB() (*bolt.DB, error) {
|
||||
usr, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(usr.HomeDir, ".config", "logbook")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbPath := filepath.Join(dir, "logbook.db")
|
||||
db, err := bolt.Open(dbPath, 0600, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func run(window *app.Window) error {
|
||||
theme := material.NewTheme()
|
||||
var ops op.Ops
|
||||
|
||||
db, err := openStandardDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open DB: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
workoutRepo, err := logbook.NewWorkoutRepository(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create workout repo: %w", err)
|
||||
}
|
||||
setRepo, err := logbook.NewSetRepository(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create set repo: %w", err)
|
||||
}
|
||||
|
||||
// Navigation state
|
||||
currentPage := pageLanding
|
||||
var planPage *planWorkoutPage
|
||||
|
||||
// For landing page state
|
||||
var planned []*logbook.Workout
|
||||
var workoutIdx int
|
||||
|
||||
// Preload planned workouts for landing page
|
||||
reloadPlanned := func() {
|
||||
now := time.Now()
|
||||
allWorkouts, err := workoutRepo.FindAll()
|
||||
if err != nil {
|
||||
planned = nil
|
||||
workoutIdx = 0
|
||||
return
|
||||
}
|
||||
planned = filterPlannedWorkoutsAfter(allWorkouts, now)
|
||||
sort.Slice(planned, func(i, j int) bool {
|
||||
return planned[i].Date.Before(planned[j].Date)
|
||||
})
|
||||
workoutIdx = 0
|
||||
}
|
||||
reloadPlanned()
|
||||
|
||||
for {
|
||||
e := window.Event()
|
||||
switch e := e.(type) {
|
||||
case app.DestroyEvent:
|
||||
return e.Err
|
||||
case app.FrameEvent:
|
||||
gtx := app.NewContext(&ops, e)
|
||||
|
||||
switch currentPage {
|
||||
case pageLanding:
|
||||
// Click handling flags
|
||||
planWorkoutClicked := false
|
||||
|
||||
layout.Flex{
|
||||
Axis: layout.Vertical,
|
||||
}.Layout(gtx,
|
||||
// Top navigation bar
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(material.Button(theme, &btnPrevWorkouts, "Previous Workouts").Layout),
|
||||
layout.Rigid(material.Button(theme, &btnPredictStrength, "Predict Strength").Layout),
|
||||
layout.Rigid(material.Button(theme, &btnMeasurements, "Measurements").Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
dims := material.Button(theme, &btnPlanWorkout, "Plan Workout").Layout(gtx)
|
||||
if btnPlanWorkout.Clicked(gtx) {
|
||||
planWorkoutClicked = true
|
||||
}
|
||||
return dims
|
||||
}),
|
||||
)
|
||||
}),
|
||||
// Main content: Next planned workout
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if len(planned) == 0 {
|
||||
return material.H5(theme, "No planned workouts.").Layout(gtx)
|
||||
}
|
||||
w := planned[workoutIdx]
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return material.H4(theme, "Next Workout: "+w.Date.Format("Mon Jan 2, 2006 3:04 PM")).Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return material.H5(theme, "Type: "+w.Type).Layout(gtx)
|
||||
}),
|
||||
// Planned sets, one per line
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
sets := getPlannedSets(setRepo, w.SetIDs)
|
||||
return list.Layout(gtx, len(sets), func(gtx layout.Context, i int) layout.Dimensions {
|
||||
set := sets[i]
|
||||
return material.Body1(theme, formatSet(set)).Layout(gtx)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
// Bottom navigation: left/right arrows
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
leftClicked := false
|
||||
rightClicked := false
|
||||
dims := layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.Button(theme, &btnLeft, "←")
|
||||
if workoutIdx == 0 {
|
||||
btn.Background = color.NRGBA{A: 80}
|
||||
}
|
||||
d := btn.Layout(gtx)
|
||||
if btnLeft.Clicked(gtx) {
|
||||
leftClicked = true
|
||||
}
|
||||
return d
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.Button(theme, &btnRight, "→")
|
||||
if workoutIdx == len(planned)-1 {
|
||||
btn.Background = color.NRGBA{A: 80}
|
||||
}
|
||||
d := btn.Layout(gtx)
|
||||
if btnRight.Clicked(gtx) {
|
||||
rightClicked = true
|
||||
}
|
||||
return d
|
||||
}),
|
||||
)
|
||||
// Handle navigation after layout
|
||||
if leftClicked && workoutIdx > 0 {
|
||||
workoutIdx--
|
||||
}
|
||||
if rightClicked && workoutIdx < len(planned)-1 {
|
||||
workoutIdx++
|
||||
}
|
||||
return dims
|
||||
}),
|
||||
)
|
||||
|
||||
// Handle navigation to Plan Workout page after layout
|
||||
if planWorkoutClicked {
|
||||
planPage = newPlanWorkoutPage()
|
||||
currentPage = pagePlanWorkout
|
||||
}
|
||||
|
||||
case pagePlanWorkout:
|
||||
planPage.Layout(gtx, theme, setRepo, workoutRepo, func() {
|
||||
reloadPlanned()
|
||||
currentPage = pageLanding
|
||||
})
|
||||
}
|
||||
|
||||
e.Frame(gtx.Ops)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func filterPlannedWorkoutsAfter(workouts []*logbook.Workout, after time.Time) []*logbook.Workout {
|
||||
var planned []*logbook.Workout
|
||||
for _, w := range workouts {
|
||||
if w.Date.After(after) {
|
||||
planned = append(planned, w)
|
||||
}
|
||||
}
|
||||
return planned
|
||||
}
|
||||
|
||||
// Use setRepo.GetByID for efficient lookup
|
||||
func getPlannedSets(setRepo *logbook.SetRepository, setIDs []string) []logbook.Set {
|
||||
var sets []logbook.Set
|
||||
for _, id := range setIDs {
|
||||
set, err := setRepo.GetByID(id)
|
||||
if err == nil && set != nil {
|
||||
sets = append(sets, *set)
|
||||
}
|
||||
}
|
||||
return sets
|
||||
}
|
||||
|
||||
func formatSet(set logbook.Set) string {
|
||||
if set.Weight > 0 {
|
||||
return fmt.Sprintf("%s: %d reps @ %.0f lb", set.Exercise, set.Reps, set.Weight)
|
||||
}
|
||||
return fmt.Sprintf("%s: %d reps (bodyweight)", set.Exercise, set.Reps)
|
||||
}
|
||||
|
||||
// --- Plan Workout Page Implementation ---
|
||||
|
||||
type planWorkoutPage struct {
|
||||
dateEditor widget.Editor
|
||||
typeEditor widget.Editor
|
||||
addSetBtn widget.Clickable
|
||||
saveBtn widget.Clickable
|
||||
sets []plannedSet
|
||||
setEditors []plannedSetEditors
|
||||
errorMsg string
|
||||
successMsg string
|
||||
backBtn widget.Clickable
|
||||
}
|
||||
|
||||
type plannedSet struct {
|
||||
Exercise string
|
||||
Reps int
|
||||
Weight float64
|
||||
}
|
||||
|
||||
type plannedSetEditors struct {
|
||||
exerciseEditor widget.Editor
|
||||
repsEditor widget.Editor
|
||||
weightEditor widget.Editor
|
||||
}
|
||||
|
||||
func newPlanWorkoutPage() *planWorkoutPage {
|
||||
p := &planWorkoutPage{}
|
||||
p.dateEditor.SingleLine = true
|
||||
p.dateEditor.SetText(time.Now().Format("2006-01-02"))
|
||||
p.typeEditor.SingleLine = true
|
||||
p.setEditors = []plannedSetEditors{newPlannedSetEditors()}
|
||||
return p
|
||||
}
|
||||
|
||||
func newPlannedSetEditors() plannedSetEditors {
|
||||
var e plannedSetEditors
|
||||
e.exerciseEditor.SingleLine = true
|
||||
e.repsEditor.SingleLine = true
|
||||
e.weightEditor.SingleLine = true
|
||||
return e
|
||||
}
|
||||
|
||||
func (p *planWorkoutPage) Layout(gtx layout.Context, th *material.Theme, setRepo *logbook.SetRepository, workoutRepo *logbook.WorkoutRepository, navBack func()) layout.Dimensions {
|
||||
var ls = new(layout.List)
|
||||
ls.Axis = layout.Vertical
|
||||
// Handle Add Set button
|
||||
if p.addSetBtn.Clicked(gtx) {
|
||||
p.setEditors = append(p.setEditors, newPlannedSetEditors())
|
||||
}
|
||||
|
||||
// Handle Save button
|
||||
if p.saveBtn.Clicked(gtx) {
|
||||
p.errorMsg = ""
|
||||
p.successMsg = ""
|
||||
// Parse date
|
||||
date, err := time.Parse("2006-01-02", p.dateEditor.Text())
|
||||
if err != nil {
|
||||
p.errorMsg = "Invalid date format (use YYYY-MM-DD)"
|
||||
} else {
|
||||
// Collect sets
|
||||
var setIDs []string
|
||||
for _, ed := range p.setEditors {
|
||||
exercise := ed.exerciseEditor.Text()
|
||||
reps, _ := parseInt(ed.repsEditor.Text())
|
||||
weight, _ := parseFloat(ed.weightEditor.Text())
|
||||
if exercise == "" || reps == 0 {
|
||||
continue // skip incomplete sets
|
||||
}
|
||||
set := &logbook.Set{
|
||||
Exercise: exercise,
|
||||
Reps: reps,
|
||||
Weight: weight,
|
||||
Date: date,
|
||||
Plan: true,
|
||||
}
|
||||
if err := setRepo.Save(set); err == nil {
|
||||
setIDs = append(setIDs, set.ID)
|
||||
}
|
||||
}
|
||||
if len(setIDs) == 0 {
|
||||
p.errorMsg = "No valid sets entered."
|
||||
} else {
|
||||
workout := &logbook.Workout{
|
||||
Date: date,
|
||||
Type: p.typeEditor.Text(),
|
||||
SetIDs: setIDs,
|
||||
}
|
||||
if err := workoutRepo.Save(workout); err != nil {
|
||||
p.errorMsg = "Failed to save workout: " + err.Error()
|
||||
} else {
|
||||
p.successMsg = "Workout planned!"
|
||||
navBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Back button
|
||||
if p.backBtn.Clicked(gtx) {
|
||||
navBack()
|
||||
}
|
||||
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(material.H4(th, "Plan Workout").Layout),
|
||||
layout.Rigid(material.Body1(th, "Date (YYYY-MM-DD):").Layout),
|
||||
layout.Rigid(material.Editor(th, &p.dateEditor, "2025-05-05").Layout),
|
||||
layout.Rigid(material.Body1(th, "Type:").Layout),
|
||||
layout.Rigid(material.Editor(th, &p.typeEditor, "e.g. Strength, Hypertrophy").Layout),
|
||||
layout.Rigid(material.Body1(th, "Sets:").Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return ls.Layout(gtx, len(p.setEditors), func(gtx layout.Context, i int) layout.Dimensions {
|
||||
ed := &p.setEditors[i]
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, material.Editor(th, &ed.exerciseEditor, "Exercise").Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, material.Editor(th, &ed.repsEditor, "Reps").Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return material.Editor(th, &ed.weightEditor, "Weight (lb)").Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(material.Button(th, &p.addSetBtn, "Add Set").Layout),
|
||||
layout.Rigid(material.Button(th, &p.saveBtn, "Save Workout").Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if p.errorMsg != "" {
|
||||
return material.Body1(th, "[Error] "+p.errorMsg).Layout(gtx)
|
||||
}
|
||||
if p.successMsg != "" {
|
||||
return material.Body1(th, p.successMsg).Layout(gtx)
|
||||
}
|
||||
return layout.Dimensions{}
|
||||
}),
|
||||
layout.Rigid(material.Button(th, &p.backBtn, "Back").Layout),
|
||||
)
|
||||
}
|
||||
|
||||
func parseInt(s string) (int, error) {
|
||||
var i int
|
||||
_, err := fmt.Sscanf(s, "%d", &i)
|
||||
return i, err
|
||||
}
|
||||
func parseFloat(s string) (float64, error) {
|
||||
var f float64
|
||||
_, err := fmt.Sscanf(s, "%f", &f)
|
||||
return f, err
|
||||
}
|
||||
|
197
cmd/logbook/main.go-backup
Normal file
197
cmd/logbook/main.go-backup
Normal file
|
@ -0,0 +1,197 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"log"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
bolt "github.com/etcd-io/bbolt"
|
||||
"git.wow.st/gmp/logbook"
|
||||
)
|
||||
|
||||
var (
|
||||
btnPrevWorkouts widget.Clickable
|
||||
btnPredictStrength widget.Clickable
|
||||
btnMeasurements widget.Clickable
|
||||
btnPlanWorkout widget.Clickable
|
||||
btnLeft widget.Clickable
|
||||
btnRight widget.Clickable
|
||||
list widget.List
|
||||
)
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
window := new(app.Window)
|
||||
if err := run(window); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
os.Exit(0)
|
||||
}()
|
||||
app.Main()
|
||||
}
|
||||
|
||||
func openStandardDB() (*bolt.DB, error) {
|
||||
usr, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(usr.HomeDir, ".config", "logbook")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbPath := filepath.Join(dir, "logbook.db")
|
||||
db, err := bolt.Open(dbPath, 0600, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func run(window *app.Window) error {
|
||||
theme := material.NewTheme()
|
||||
var ops op.Ops
|
||||
|
||||
// --- Open database and repositories ---
|
||||
db, err := openStandardDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open DB: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
workoutRepo, err := logbook.NewWorkoutRepository(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create workout repo: %w", err)
|
||||
}
|
||||
setRepo, err := logbook.NewSetRepository(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create set repo: %w", err)
|
||||
}
|
||||
|
||||
// --- Load all planned workouts after now ---
|
||||
now := time.Now()
|
||||
allWorkouts, err := workoutRepo.FindAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load workouts: %w", err)
|
||||
}
|
||||
planned := filterPlannedWorkoutsAfter(allWorkouts, now)
|
||||
sort.Slice(planned, func(i, j int) bool {
|
||||
return planned[i].Date.Before(planned[j].Date)
|
||||
})
|
||||
workoutIdx := 0
|
||||
|
||||
for {
|
||||
e := window.Event()
|
||||
switch e := e.(type) {
|
||||
case app.DestroyEvent:
|
||||
return e.Err
|
||||
case app.FrameEvent:
|
||||
gtx := app.NewContext(&ops, e)
|
||||
|
||||
// Handle left/right navigation
|
||||
if btnLeft.Clicked(gtx) && workoutIdx > 0 {
|
||||
workoutIdx--
|
||||
}
|
||||
if btnRight.Clicked(gtx) && workoutIdx < len(planned)-1 {
|
||||
workoutIdx++
|
||||
}
|
||||
|
||||
layout.Flex{
|
||||
Axis: layout.Vertical,
|
||||
}.Layout(gtx,
|
||||
// Top navigation bar
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(material.Button(theme, &btnPrevWorkouts, "Previous Workouts").Layout),
|
||||
layout.Rigid(material.Button(theme, &btnPredictStrength, "Predict Strength").Layout),
|
||||
layout.Rigid(material.Button(theme, &btnMeasurements, "Measurements").Layout),
|
||||
layout.Rigid(material.Button(theme, &btnPlanWorkout, "Plan Workout").Layout),
|
||||
)
|
||||
}),
|
||||
// Main content: Next planned workout
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if len(planned) == 0 {
|
||||
return material.H5(theme, "No planned workouts.").Layout(gtx)
|
||||
}
|
||||
w := planned[workoutIdx]
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return material.H4(theme, "Next Workout: "+w.Date.Format("Mon Jan 2, 2006 3:04 PM")).Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return material.H5(theme, "Type: "+w.Type).Layout(gtx)
|
||||
}),
|
||||
// Planned sets, one per line
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
sets := getPlannedSets(setRepo, w.SetIDs)
|
||||
return list.Layout(gtx, len(sets), func(gtx layout.Context, i int) layout.Dimensions {
|
||||
set := sets[i]
|
||||
return material.Body1(theme, formatSet(set)).Layout(gtx)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
// Bottom navigation: left/right arrows
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.Button(theme, &btnLeft, "←")
|
||||
if workoutIdx == 0 {
|
||||
btn.Background = color.NRGBA{A: 80}
|
||||
}
|
||||
return btn.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.Button(theme, &btnRight, "→")
|
||||
if workoutIdx == len(planned)-1 {
|
||||
btn.Background = color.NRGBA{A: 80}
|
||||
}
|
||||
return btn.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
e.Frame(gtx.Ops)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter for planned workouts after the current date
|
||||
func filterPlannedWorkoutsAfter(workouts []*logbook.Workout, after time.Time) []*logbook.Workout {
|
||||
var planned []*logbook.Workout
|
||||
for _, w := range workouts {
|
||||
if w.Date.After(after) {
|
||||
planned = append(planned, w)
|
||||
}
|
||||
}
|
||||
return planned
|
||||
}
|
||||
|
||||
// Load planned sets for a workout from the SetRepository
|
||||
func getPlannedSets(setRepo *logbook.SetRepository, setIDs []string) []logbook.Set {
|
||||
var sets []logbook.Set
|
||||
for _, id := range setIDs {
|
||||
set, err := setRepo.GetByID(id)
|
||||
if err == nil && set != nil {
|
||||
sets = append(sets, *set)
|
||||
}
|
||||
}
|
||||
return sets
|
||||
}
|
||||
|
||||
// Format a set as a string for display
|
||||
func formatSet(set logbook.Set) string {
|
||||
if set.Weight > 0 {
|
||||
return fmt.Sprintf("%s: %d reps @ %.0f lb", set.Exercise, set.Reps, set.Weight)
|
||||
}
|
||||
return fmt.Sprintf("%s: %d reps (bodyweight)", set.Exercise, set.Reps)
|
||||
}
|
25
go.mod
Normal file
25
go.mod
Normal file
|
@ -0,0 +1,25 @@
|
|||
module git.wow.st/gmp/logbook
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
gioui.org v0.8.0
|
||||
github.com/aclements/go-moremath v0.0.0-20241023150245-c8bbc672ef66
|
||||
github.com/chewxy/stl v1.3.1
|
||||
github.com/etcd-io/bbolt v1.3.3
|
||||
github.com/google/uuid v1.6.0
|
||||
go.etcd.io/bbolt v1.4.0
|
||||
gonum.org/v1/gonum v0.15.1
|
||||
)
|
||||
|
||||
require (
|
||||
gioui.org/shader v1.0.8 // indirect
|
||||
github.com/go-text/typesetting v0.2.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect
|
||||
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
golang.org/x/tools v0.23.0 // indirect
|
||||
)
|
33
go.sum
Normal file
33
go.sum
Normal file
|
@ -0,0 +1,33 @@
|
|||
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
|
||||
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
|
||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||
github.com/aclements/go-moremath v0.0.0-20241023150245-c8bbc672ef66 h1:siNQlUMcFUDZWCOt0p+RHl7et5Nnwwyq/sFZmr4iG1I=
|
||||
github.com/aclements/go-moremath v0.0.0-20241023150245-c8bbc672ef66/go.mod h1:FDw7qicTbJ1y1SZcNnOvym2BogPdC3lY9Z1iUM4MVhw=
|
||||
github.com/chewxy/stl v1.3.1 h1:5IblZvGtZGzYL2xN6ZJ1uIyKauYmKK42axeeHQRf5ig=
|
||||
github.com/chewxy/stl v1.3.1/go.mod h1:rahF/zUIXOWT3RDM7k/ytPYqf+gSbUh+Px6EyhE4R0M=
|
||||
github.com/etcd-io/bbolt v1.3.3 h1:gSJmxrs37LgTqR/oyJBWok6k6SvXEUerFTbltIhXkBM=
|
||||
github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
|
||||
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
|
||||
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
|
||||
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
|
||||
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w=
|
||||
golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU=
|
||||
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
|
||||
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
|
475
main.go
Normal file
475
main.go
Normal file
|
@ -0,0 +1,475 @@
|
|||
package logbook
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.wow.st/gmp/logbook/parser"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// --- Bucket names ---
|
||||
|
||||
var (
|
||||
bucketSets = []byte("sets")
|
||||
bucketSetGroups = []byte("setgroups")
|
||||
bucketWorkouts = []byte("workouts")
|
||||
)
|
||||
|
||||
// WorkoutRepository ---
|
||||
|
||||
type WorkoutRepository struct {
|
||||
db *bolt.DB
|
||||
}
|
||||
|
||||
func NewWorkoutRepository(db *bolt.DB) (*WorkoutRepository, error) {
|
||||
err := db.Update(func(tx *bolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists(bucketSets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.CreateBucketIfNotExists(bucketSetGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.CreateBucketIfNotExists(bucketWorkouts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &WorkoutRepository{db: db}, nil
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
func getStruct(b *bolt.Bucket, id string, v interface{}) error {
|
||||
if b == nil {
|
||||
return errors.New("bucket does not exist")
|
||||
}
|
||||
data := b.Get([]byte(id))
|
||||
if data == nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
func putStruct(b *bolt.Bucket, id string, v interface{}) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put([]byte(id), data)
|
||||
}
|
||||
|
||||
func deleteStruct(b *bolt.Bucket, id string) error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return b.Delete([]byte(id))
|
||||
}
|
||||
|
||||
// --- ID generation (RFC3339 date + context + random suffix) ---
|
||||
|
||||
func generateDateID(date time.Time, suffix string) string {
|
||||
base := date.UTC().Format("2006-01-02T150405Z07")
|
||||
if suffix != "" {
|
||||
base += "-" + sanitizeForID(suffix)
|
||||
}
|
||||
randBytes := make([]byte, 4)
|
||||
_, _ = rand.Read(randBytes)
|
||||
return fmt.Sprintf("%s-%s", base, hex.EncodeToString(randBytes))
|
||||
}
|
||||
|
||||
func sanitizeForID(s string) string {
|
||||
out := ""
|
||||
for _, ch := range s {
|
||||
if ch == ' ' || ch == '/' || ch == '\\' {
|
||||
out += "_"
|
||||
} else {
|
||||
out += string(ch)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Data Structures (for DB storage) ---
|
||||
|
||||
type Set struct {
|
||||
ID string
|
||||
Weight float64
|
||||
Reps int
|
||||
Note string
|
||||
Time time.Time
|
||||
RIR int
|
||||
}
|
||||
|
||||
type SetGroup struct {
|
||||
ID string
|
||||
Exercise string
|
||||
Type string
|
||||
Note string
|
||||
PlannedSetIDs []string
|
||||
ActualSetIDs []string
|
||||
}
|
||||
|
||||
type Workout struct {
|
||||
ID string
|
||||
Date time.Time
|
||||
Type string
|
||||
Note string
|
||||
SetGroupIDs []string
|
||||
}
|
||||
|
||||
// --- AddWorkoutsFromIR (with bucket creation) ---
|
||||
|
||||
func (r *WorkoutRepository) AddWorkoutsFromIR(irWorkouts []parser.Workout) error {
|
||||
db := r.db
|
||||
if db == nil {
|
||||
return fmt.Errorf("Workout Repository is not initialized.")
|
||||
}
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
// Ensure buckets exist
|
||||
setsB, err := tx.CreateBucketIfNotExists(bucketSets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setGroupsB, err := tx.CreateBucketIfNotExists(bucketSetGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workoutsB, err := tx.CreateBucketIfNotExists(bucketWorkouts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, irW := range irWorkouts {
|
||||
workoutID := generateDateID(irW.Date, "")
|
||||
w := &Workout{
|
||||
ID: workoutID,
|
||||
Date: irW.Date,
|
||||
Type: irW.Type,
|
||||
Note: irW.Note,
|
||||
SetGroupIDs: nil,
|
||||
}
|
||||
|
||||
for gidx, irG := range irW.SetGroups {
|
||||
setGroupID := generateDateID(irW.Date, fmt.Sprintf("%s-%d", irG.Exercise, gidx))
|
||||
sg := &SetGroup{
|
||||
ID: setGroupID,
|
||||
Exercise: irG.Exercise,
|
||||
Type: irG.Type,
|
||||
Note: irG.Note,
|
||||
PlannedSetIDs: nil,
|
||||
ActualSetIDs: nil,
|
||||
}
|
||||
|
||||
// Add Planned Sets
|
||||
for i, irSet := range irG.PlannedSets {
|
||||
setID := generateDateID(irW.Date, fmt.Sprintf("%s-planned-%d", irG.Exercise, i))
|
||||
set := &Set{
|
||||
ID: setID,
|
||||
Weight: irSet.Weight,
|
||||
Reps: irSet.Reps,
|
||||
Note: irSet.Note,
|
||||
Time: irSet.Time,
|
||||
RIR: irSet.RIR,
|
||||
}
|
||||
if err := putStruct(setsB, set.ID, set); err != nil {
|
||||
return fmt.Errorf("failed to store planned set: %w", err)
|
||||
}
|
||||
sg.PlannedSetIDs = append(sg.PlannedSetIDs, set.ID)
|
||||
}
|
||||
|
||||
// Add Actual Sets
|
||||
for i, irSet := range irG.ActualSets {
|
||||
setID := generateDateID(irW.Date, fmt.Sprintf("%s-actual-%d", irG.Exercise, i))
|
||||
set := &Set{
|
||||
ID: setID,
|
||||
Weight: irSet.Weight,
|
||||
Reps: irSet.Reps,
|
||||
Note: irSet.Note,
|
||||
Time: irSet.Time,
|
||||
RIR: irSet.RIR,
|
||||
}
|
||||
if err := putStruct(setsB, set.ID, set); err != nil {
|
||||
return fmt.Errorf("failed to store actual set: %w", err)
|
||||
}
|
||||
sg.ActualSetIDs = append(sg.ActualSetIDs, set.ID)
|
||||
}
|
||||
|
||||
// Store SetGroup
|
||||
if err := putStruct(setGroupsB, sg.ID, sg); err != nil {
|
||||
return fmt.Errorf("failed to store set group: %w", err)
|
||||
}
|
||||
w.SetGroupIDs = append(w.SetGroupIDs, sg.ID)
|
||||
}
|
||||
|
||||
// Store Workout
|
||||
if err := putStruct(workoutsB, w.ID, w); err != nil {
|
||||
return fmt.Errorf("failed to store workout: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// --- GET METHODS ---
|
||||
|
||||
func (r *WorkoutRepository) GetWorkout(id string) (*parser.Workout, error) {
|
||||
db := r.db
|
||||
var w Workout
|
||||
err := db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketWorkouts)
|
||||
if b == nil {
|
||||
return errors.New("workouts bucket does not exist")
|
||||
}
|
||||
return getStruct(b, id, &w)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ir := &parser.Workout{
|
||||
Date: w.Date,
|
||||
Type: w.Type,
|
||||
Note: w.Note,
|
||||
SetGroups: nil,
|
||||
}
|
||||
for _, sgid := range w.SetGroupIDs {
|
||||
sg, err := r.GetSetGroup(sgid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get setgroup: %w", err)
|
||||
}
|
||||
ir.SetGroups = append(ir.SetGroups, *sg)
|
||||
}
|
||||
return ir, nil
|
||||
}
|
||||
|
||||
func (r *WorkoutRepository) GetSetGroup(id string) (*parser.SetGroup, error) {
|
||||
db := r.db
|
||||
var sg SetGroup
|
||||
err := db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketSetGroups)
|
||||
if b == nil {
|
||||
return errors.New("setgroups bucket does not exist")
|
||||
}
|
||||
return getStruct(b, id, &sg)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ir := &parser.SetGroup{
|
||||
Exercise: sg.Exercise,
|
||||
Type: sg.Type,
|
||||
Note: sg.Note,
|
||||
PlannedSets: nil,
|
||||
ActualSets: nil,
|
||||
}
|
||||
for _, setid := range sg.PlannedSetIDs {
|
||||
s, err := r.GetSet(setid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get planned set: %w", err)
|
||||
}
|
||||
ir.PlannedSets = append(ir.PlannedSets, *s)
|
||||
}
|
||||
for _, setid := range sg.ActualSetIDs {
|
||||
s, err := r.GetSet(setid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get actual set: %w", err)
|
||||
}
|
||||
ir.ActualSets = append(ir.ActualSets, *s)
|
||||
}
|
||||
return ir, nil
|
||||
}
|
||||
|
||||
func (r *WorkoutRepository) GetSet(id string) (*parser.Set, error) {
|
||||
db := r.db
|
||||
var s Set
|
||||
err := db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketSets)
|
||||
if b == nil {
|
||||
return errors.New("sets bucket does not exist")
|
||||
}
|
||||
return getStruct(b, id, &s)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &parser.Set{
|
||||
Weight: s.Weight,
|
||||
Reps: s.Reps,
|
||||
Note: s.Note,
|
||||
Time: s.Time,
|
||||
RIR: s.RIR,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- FIND METHODS ---
|
||||
|
||||
// FindWorkouts returns a slice of workouts in parser.Workout format
|
||||
// (intermediate representation) as well as a slice of workout IDs.
|
||||
func (r *WorkoutRepository) FindWorkouts(match func(*parser.Workout) bool) ([]*parser.Workout, []string, error) {
|
||||
db := r.db
|
||||
var ws []*parser.Workout
|
||||
var ids []string
|
||||
err := db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketWorkouts)
|
||||
if b == nil {
|
||||
// No workouts bucket yet; return empty list, no error
|
||||
return nil
|
||||
}
|
||||
return b.ForEach(func(k, v []byte) error {
|
||||
var w Workout
|
||||
if err := json.Unmarshal(v, &w); err != nil {
|
||||
return err
|
||||
}
|
||||
ir := &parser.Workout{
|
||||
Date: w.Date,
|
||||
Type: w.Type,
|
||||
Note: w.Note,
|
||||
SetGroups: nil,
|
||||
}
|
||||
for _, sgid := range w.SetGroupIDs {
|
||||
sg, err := r.GetSetGroup(sgid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ir.SetGroups = append(ir.SetGroups, *sg)
|
||||
}
|
||||
if match(ir) {
|
||||
ws = append(ws, ir)
|
||||
ids = append(ids, w.ID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return ws, ids, err
|
||||
}
|
||||
|
||||
func (r *WorkoutRepository) FindSetGroups(match func(*parser.SetGroup) bool) ([]*parser.SetGroup, error) {
|
||||
db := r.db
|
||||
var out []*parser.SetGroup
|
||||
err := db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketSetGroups)
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return b.ForEach(func(k, v []byte) error {
|
||||
var sg SetGroup
|
||||
if err := json.Unmarshal(v, &sg); err != nil {
|
||||
return err
|
||||
}
|
||||
ir := &parser.SetGroup{
|
||||
Exercise: sg.Exercise,
|
||||
Type: sg.Type,
|
||||
Note: sg.Note,
|
||||
PlannedSets: nil,
|
||||
ActualSets: nil,
|
||||
}
|
||||
for _, setid := range sg.PlannedSetIDs {
|
||||
s, err := r.GetSet(setid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ir.PlannedSets = append(ir.PlannedSets, *s)
|
||||
}
|
||||
for _, setid := range sg.ActualSetIDs {
|
||||
s, err := r.GetSet(setid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ir.ActualSets = append(ir.ActualSets, *s)
|
||||
}
|
||||
if match(ir) {
|
||||
out = append(out, ir)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (r *WorkoutRepository) FindSets(match func(*parser.Set) bool) ([]*parser.Set, error) {
|
||||
db := r.db
|
||||
var out []*parser.Set
|
||||
err := db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketSets)
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return b.ForEach(func(k, v []byte) error {
|
||||
var s Set
|
||||
if err := json.Unmarshal(v, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
ir := &parser.Set{
|
||||
Weight: s.Weight,
|
||||
Reps: s.Reps,
|
||||
Note: s.Note,
|
||||
Time: s.Time,
|
||||
}
|
||||
if match(ir) {
|
||||
out = append(out, ir)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// --- UPDATE METHODS (stubs: see previous answer for logic) ---
|
||||
|
||||
func (r *WorkoutRepository) UpdateWorkout(updated *parser.Workout) error {
|
||||
return errors.New("UpdateWorkout not implemented")
|
||||
}
|
||||
|
||||
func (r *WorkoutRepository) UpdateSetGroup(updated *parser.SetGroup) error {
|
||||
return errors.New("UpdateSetGroup not implemented")
|
||||
}
|
||||
|
||||
func (r *WorkoutRepository) UpdateSet(updated *parser.Set) error {
|
||||
return errors.New("UpdateSet not implemented")
|
||||
}
|
||||
|
||||
// --- DELETE METHOD ---
|
||||
|
||||
func (r *WorkoutRepository) DeleteWorkout(id string) error {
|
||||
db := r.db
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
bW := tx.Bucket(bucketWorkouts)
|
||||
if bW == nil {
|
||||
return errors.New("workouts bucket does not exist")
|
||||
}
|
||||
var w Workout
|
||||
if err := getStruct(bW, id, &w); err != nil {
|
||||
return err
|
||||
}
|
||||
bSG := tx.Bucket(bucketSetGroups)
|
||||
bS := tx.Bucket(bucketSets)
|
||||
for _, sgid := range w.SetGroupIDs {
|
||||
var sg SetGroup
|
||||
if bSG != nil {
|
||||
if err := getStruct(bSG, sgid, &sg); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, setid := range sg.PlannedSetIDs {
|
||||
_ = deleteStruct(bS, setid)
|
||||
}
|
||||
for _, setid := range sg.ActualSetIDs {
|
||||
_ = deleteStruct(bS, setid)
|
||||
}
|
||||
_ = deleteStruct(bSG, sgid)
|
||||
}
|
||||
}
|
||||
return deleteStruct(bW, id)
|
||||
})
|
||||
}
|
||||
|
147
measurement.go
Normal file
147
measurement.go
Normal file
|
@ -0,0 +1,147 @@
|
|||
package logbook
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
"git.wow.st/gmp/logbook/parser"
|
||||
)
|
||||
|
||||
// --- Measurement Data Structure ---
|
||||
|
||||
type Measurement struct {
|
||||
ID string // Unique identifier
|
||||
Date time.Time // When the measurement was taken
|
||||
Variable string // e.g. "Bodyweight", "Waist", "Biceps"
|
||||
Value float64 // The actual measurement value
|
||||
Note string // Optional notes
|
||||
}
|
||||
|
||||
// --- BoltDB Bucket Name ---
|
||||
|
||||
var bucketMeasurements = []byte("measurements")
|
||||
|
||||
// --- MeasurementRepository ---
|
||||
|
||||
type MeasurementRepository struct {
|
||||
db *bolt.DB
|
||||
}
|
||||
|
||||
func NewMeasurementRepository(db *bolt.DB) (*MeasurementRepository, error) {
|
||||
err := db.Update(func(tx *bolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists(bucketMeasurements)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &MeasurementRepository{db: db}, nil
|
||||
}
|
||||
|
||||
func (r *MeasurementRepository) Save(m *Measurement) error {
|
||||
if m.ID == "" {
|
||||
m.ID = generateDateID(m.Date, "measurement-" + "m.Variable")
|
||||
}
|
||||
return r.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketMeasurements)
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put([]byte(m.ID), data)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *MeasurementRepository) GetByID(id string) (*Measurement, error) {
|
||||
var m Measurement
|
||||
err := r.db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketMeasurements)
|
||||
v := b.Get([]byte(id))
|
||||
if v == nil {
|
||||
return errors.New("measurement not found")
|
||||
}
|
||||
return json.Unmarshal(v, &m)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *MeasurementRepository) FindAll() ([]*Measurement, error) {
|
||||
var measurements []*Measurement
|
||||
err := r.db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketMeasurements)
|
||||
return b.ForEach(func(k, v []byte) error {
|
||||
var m Measurement
|
||||
if err := json.Unmarshal(v, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
measurements = append(measurements, &m)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return measurements, err
|
||||
}
|
||||
|
||||
func (r *MeasurementRepository) Delete(id string) error {
|
||||
return r.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketMeasurements)
|
||||
return b.Delete([]byte(id))
|
||||
})
|
||||
}
|
||||
|
||||
// --- AddMeasurementsFromIR (with bucket creation) ---
|
||||
|
||||
func (r *MeasurementRepository) AddMeasurementsFromIR(irMeasurements []parser.Measurement) error {
|
||||
db := r.db
|
||||
if db == nil {
|
||||
return fmt.Errorf("Measurement Repository is not initialized.")
|
||||
}
|
||||
for _, m := range(irMeasurements) {
|
||||
measurementID := generateDateID(m.Date, m.Variable)
|
||||
mm := &Measurement{
|
||||
ID: measurementID,
|
||||
Date: m.Date,
|
||||
Variable: m.Variable,
|
||||
Value: m.Value,
|
||||
Note: m.Note,
|
||||
}
|
||||
err := r.Save(mm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MeasurementRepository) GetOrPredict(variable string, date time.Time) (float64, error) {
|
||||
var ret float64
|
||||
id := date.UTC().Format("2006-01-02")
|
||||
err := r.db.View(func(tx *bolt.Tx) error {
|
||||
c := tx.Bucket(bucketMeasurements).Cursor()
|
||||
k,v := c.Seek([]byte(id))
|
||||
if k != nil {
|
||||
var m Measurement
|
||||
if err := json.Unmarshal(v, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
ret = m.Value
|
||||
} else {
|
||||
x, err := r.Predict(variable, date, 0.75)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ret = x
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
37
parser/ir.go
Normal file
37
parser/ir.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
package parser
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- IR Types ---
|
||||
|
||||
type Workout struct {
|
||||
Date time.Time
|
||||
Type string
|
||||
Note string
|
||||
SetGroups []SetGroup
|
||||
}
|
||||
|
||||
type SetGroup struct {
|
||||
Exercise string
|
||||
Type string
|
||||
Note string
|
||||
PlannedSets []Set
|
||||
ActualSets []Set
|
||||
}
|
||||
|
||||
type Set struct {
|
||||
Weight float64
|
||||
Reps int
|
||||
Note string
|
||||
Time time.Time
|
||||
RIR int
|
||||
}
|
||||
|
||||
type Measurement struct {
|
||||
Date time.Time
|
||||
Variable string
|
||||
Value float64
|
||||
Note string
|
||||
}
|
371
parser/parser.go
Normal file
371
parser/parser.go
Normal file
|
@ -0,0 +1,371 @@
|
|||
package parser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParseDSL parses the whitespace-structured DSL and returns a slice of Workouts and a slice of Measurements.
|
||||
func ParseDSL(input string) ([]Workout, []Measurement, error) {
|
||||
lines := strings.Split(input, "\n")
|
||||
var workouts []Workout
|
||||
var measurements []Measurement
|
||||
var curWorkout *Workout
|
||||
var curSetGroup *SetGroup
|
||||
|
||||
for idx, rawLine := range lines {
|
||||
line := strings.TrimRight(rawLine, "\r\n")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
indent := countIndent(line)
|
||||
content := strings.TrimLeft(line, " \t")
|
||||
var setIndex int
|
||||
|
||||
switch indent {
|
||||
case 0:
|
||||
// either a workout or a measurement
|
||||
parts := strings.SplitN(line, " ", 3)
|
||||
if (parts[1] == "measurement") {
|
||||
// Measurement line: date measurement Variable=Value [note]
|
||||
date, variable, value, note, err := parseMeasurementLine(content)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("line %d: %v", idx+1, err)
|
||||
}
|
||||
measurements = append(measurements, Measurement{
|
||||
Date: date,
|
||||
Variable: variable,
|
||||
Value: value,
|
||||
Note: note,
|
||||
})
|
||||
} else if (parts[1] == "workout") {
|
||||
// Workout line: date workout [type in (parentheses)] [note]
|
||||
date, typ, note, err := parseWorkoutLine(content)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("line %d: %v", idx+1, err)
|
||||
}
|
||||
workouts = append(workouts, Workout{
|
||||
Date: date,
|
||||
Type: typ,
|
||||
Note: note,
|
||||
SetGroups: nil,
|
||||
})
|
||||
curWorkout = &workouts[len(workouts)-1]
|
||||
curSetGroup = nil
|
||||
} else {
|
||||
return nil, nil, fmt.Errorf("Invalid top level item: %s.\n",parts[1])
|
||||
}
|
||||
case 1:
|
||||
// SetGroup line (including planned sets)
|
||||
if curWorkout == nil {
|
||||
return nil, nil, fmt.Errorf("line %d: SetGroup without Workout", idx+1)
|
||||
}
|
||||
sg, err := parseSetGroupLine(content)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("line %d: %v", idx+1, err)
|
||||
}
|
||||
curWorkout.SetGroups = append(curWorkout.SetGroups, sg)
|
||||
curSetGroup = &curWorkout.SetGroups[len(curWorkout.SetGroups)-1]
|
||||
setIndex = 0
|
||||
case 2:
|
||||
// Actual Set line (may be multiple sets separated by "-")
|
||||
if curSetGroup == nil {
|
||||
return nil, nil, fmt.Errorf("line %d: Actual Set without SetGroup", idx+1)
|
||||
}
|
||||
sets, weights, err := parseActualSetLine(content,curWorkout.Date)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("line %d: %v", idx+1, err)
|
||||
}
|
||||
for i := range(sets) {
|
||||
if !weights[i] {
|
||||
sets[i].Weight = curSetGroup.PlannedSets[setIndex].Weight
|
||||
}
|
||||
if setIndex < len(curSetGroup.PlannedSets) -1 {
|
||||
setIndex++
|
||||
}
|
||||
}
|
||||
curSetGroup.ActualSets = append(curSetGroup.ActualSets, sets...)
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("line %d: Unexpected indentation", idx+1)
|
||||
}
|
||||
}
|
||||
|
||||
return workouts, measurements, nil
|
||||
}
|
||||
|
||||
// countIndent returns the number of leading whitespace "levels" (spaces or tabs).
|
||||
// 0 = Workout, 1 = SetGroup, 2 = ActualSet.
|
||||
func countIndent(line string) int {
|
||||
indent := 0
|
||||
for _, ch := range line {
|
||||
if ch == ' ' || ch == '\t' {
|
||||
indent++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return indent
|
||||
}
|
||||
|
||||
// parseMeasurementLine splits the Measurement line into the date, variable,
|
||||
// value, and note fields.
|
||||
func parseMeasurementLine(line string) (time.Time, string, float64, string, error) {
|
||||
parts := strings.SplitN(line, " ", 3)
|
||||
dateStr := parts[0]
|
||||
date, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return time.Time{}, "", 0, "", fmt.Errorf("invalid date: %v", err)
|
||||
}
|
||||
parts = strings.SplitN(parts[2],"=",2)
|
||||
variable := parts[0]
|
||||
value, _, err := parseWeightAndUnit(parts[1])
|
||||
if err != nil {
|
||||
return time.Time{}, "", 0, "", fmt.Errorf("Cannot parse measurement value.\n")
|
||||
}
|
||||
note := ""
|
||||
if len(parts) > 2 {
|
||||
note = parts[3]
|
||||
}
|
||||
return date, variable, value, note, nil
|
||||
|
||||
}
|
||||
|
||||
// parseWorkoutLine splits the Workout line into date, type, note.
|
||||
// Date is the first non-whitespace field, type is in balanced parentheses, note is the rest.
|
||||
func parseWorkoutLine(line string) (time.Time, string, string, error) {
|
||||
parts := strings.SplitN(line, " ", 3)
|
||||
dateStr := parts[0]
|
||||
date, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return time.Time{}, "", "", fmt.Errorf("invalid date: %v", err)
|
||||
}
|
||||
typ := ""
|
||||
note := ""
|
||||
if len(parts) == 3 {
|
||||
rest := strings.TrimSpace(parts[2])
|
||||
// Look for type in balanced parentheses
|
||||
if strings.HasPrefix(rest, "(") {
|
||||
depth := 0
|
||||
endIdx := -1
|
||||
for i, ch := range rest {
|
||||
if ch == '(' {
|
||||
depth++
|
||||
} else if ch == ')' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
endIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if endIdx != -1 {
|
||||
typ = strings.TrimSpace(rest[1:endIdx])
|
||||
note = strings.TrimSpace(rest[endIdx+1:])
|
||||
} else {
|
||||
// Unbalanced, treat as note
|
||||
note = rest
|
||||
}
|
||||
} else {
|
||||
note = rest
|
||||
}
|
||||
}
|
||||
return date, typ, note, nil
|
||||
}
|
||||
|
||||
// parseSetGroupLine parses a SetGroup line.
|
||||
// The first field(s) are the exercise (may contain spaces, but never a number).
|
||||
// The next field is the weight, then setsxreps, then optional type (in parentheses), then optional note.
|
||||
func parseSetGroupLine(line string) (SetGroup, error) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
return SetGroup{}, fmt.Errorf("not enough fields for SetGroup")
|
||||
}
|
||||
// Find where the weight starts (first field with a digit)
|
||||
exerciseFields := []string{}
|
||||
weightIdx := -1
|
||||
for i, f := range fields {
|
||||
if hasDigit(f) {
|
||||
weightIdx = i
|
||||
break
|
||||
}
|
||||
exerciseFields = append(exerciseFields, f)
|
||||
}
|
||||
if weightIdx == -1 || weightIdx+2 > len(fields) {
|
||||
return SetGroup{}, fmt.Errorf("no weight or setsxreps found in SetGroup line")
|
||||
}
|
||||
exercise := strings.Join(exerciseFields, " ")
|
||||
weightField := fields[weightIdx]
|
||||
setsxrepsField := fields[weightIdx+1]
|
||||
|
||||
// Parse weight and optional unit
|
||||
weight, unit, err := parseWeightAndUnit(weightField)
|
||||
if err != nil {
|
||||
return SetGroup{}, fmt.Errorf("invalid weight: %v", err)
|
||||
}
|
||||
if unit == "kg" {
|
||||
weight = weight * 2.20462 // convert to pounds
|
||||
}
|
||||
|
||||
// Parse setsxreps (e.g. 5x3)
|
||||
sets, reps, err := parseSetsXReps(setsxrepsField)
|
||||
if err != nil {
|
||||
return SetGroup{}, fmt.Errorf("invalid setsxreps: %v", err)
|
||||
}
|
||||
|
||||
// Remaining fields: optional type (in parentheses), then optional note
|
||||
typ := ""
|
||||
note := ""
|
||||
rest := strings.Join(fields[weightIdx+2:], " ")
|
||||
rest = strings.TrimSpace(rest)
|
||||
if strings.HasPrefix(rest, "(") {
|
||||
// Find end of type in parentheses
|
||||
depth := 0
|
||||
endIdx := -1
|
||||
for i, ch := range rest {
|
||||
if ch == '(' {
|
||||
depth++
|
||||
} else if ch == ')' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
endIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if endIdx != -1 {
|
||||
typ = strings.TrimSpace(rest[1:endIdx])
|
||||
note = strings.TrimSpace(rest[endIdx+1:])
|
||||
} else {
|
||||
// Unbalanced, treat as note
|
||||
note = rest
|
||||
}
|
||||
} else {
|
||||
note = rest
|
||||
}
|
||||
|
||||
// Build planned sets
|
||||
plannedSets := make([]Set, sets)
|
||||
for i := 0; i < sets; i++ {
|
||||
plannedSets[i] = Set{
|
||||
Weight: weight,
|
||||
Reps: reps,
|
||||
}
|
||||
}
|
||||
|
||||
return SetGroup{
|
||||
Exercise: exercise,
|
||||
Type: typ,
|
||||
Note: note,
|
||||
PlannedSets: plannedSets,
|
||||
ActualSets: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hasDigit(s string) bool {
|
||||
for _, ch := range s {
|
||||
if ch >= '0' && ch <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseWeightAndUnit parses a weight field, e.g. "100kg", "225", "225lb"
|
||||
func parseWeightAndUnit(field string) (float64, string, error) {
|
||||
// Find where the first non-digit or non-dot occurs
|
||||
i := 0
|
||||
for ; i < len(field); i++ {
|
||||
if (field[i] < '0' || field[i] > '9') && field[i] != '.' {
|
||||
break
|
||||
}
|
||||
}
|
||||
weightStr := field[:i]
|
||||
unit := strings.ToLower(field[i:])
|
||||
weight, err := strconv.ParseFloat(weightStr, 64)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return weight, unit, nil
|
||||
}
|
||||
|
||||
// parseSetsXReps parses a setsxreps field, e.g. "5x3"
|
||||
func parseSetsXReps(field string) (int, int, error) {
|
||||
parts := strings.Split(field, "x")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("expected format setsxreps, got %q", field)
|
||||
}
|
||||
sets, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid sets: %v", err)
|
||||
}
|
||||
reps, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid reps: %v", err)
|
||||
}
|
||||
return sets, reps, nil
|
||||
}
|
||||
|
||||
// parseActualSetLine parses an Actual Set line (can be multiple sets separated by "-").
|
||||
// The first field is set data (possibly with "-"), the rest is an optional note.
|
||||
func parseActualSetLine(line string, date time.Time) ([]Set, []bool, error) {
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
setsPart := parts[0]
|
||||
note := ""
|
||||
if len(parts) == 2 {
|
||||
note = strings.TrimSpace(parts[1])
|
||||
}
|
||||
setStrs := strings.Split(setsPart, "-")
|
||||
sets := []Set{}
|
||||
weights := []bool{}
|
||||
for _, s := range setStrs {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
// Parse as either "reps", "weightxreps", "weightunitxreps"
|
||||
weight := 0.0
|
||||
reps := 0
|
||||
if strings.Contains(s, "x") {
|
||||
parts := strings.SplitN(s, "x", 2)
|
||||
w, u, err := parseWeightAndUnit(parts[0])
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid weight in actual set: %v", err)
|
||||
}
|
||||
if u == "kg" {
|
||||
w = w * 2.20462
|
||||
}
|
||||
weights = append(weights,true)
|
||||
weight = w
|
||||
reps, err = strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid reps in actual set: %v", err)
|
||||
}
|
||||
} else {
|
||||
// Just reps, no weight
|
||||
var err error
|
||||
reps, err = strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid reps in actual set: %v", err)
|
||||
}
|
||||
weights = append(weights,false)
|
||||
}
|
||||
sets = append(sets, Set{
|
||||
Weight: weight,
|
||||
Reps: reps,
|
||||
Note: "",
|
||||
Time: date,
|
||||
RIR: -1,
|
||||
})
|
||||
}
|
||||
if note != "" && len(sets) > 0 {
|
||||
sets[len(sets)-1].Note = note
|
||||
if strings.HasPrefix(note, "max") {
|
||||
sets[len(sets)-1].RIR = 0
|
||||
}
|
||||
}
|
||||
return sets, weights, nil
|
||||
}
|
||||
|
74
predict.go
Normal file
74
predict.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
package logbook
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/aclements/go-moremath/fit"
|
||||
)
|
||||
|
||||
// Predictable expresses that values can be predicted at arbitrary times.
|
||||
type Predictable interface {
|
||||
// Predict predicts the value of a variable at the given date (using LOESS).
|
||||
// Returns the predicted value and error.
|
||||
Predict(variable string, date time.Time, span float64) (float64, error)
|
||||
}
|
||||
|
||||
// Ensure MeasurementRepository implements Predictable
|
||||
var _ Predictable = (*MeasurementRepository)(nil)
|
||||
|
||||
|
||||
// Predict uses LOESS to predict the value of a variable at a given date.
|
||||
// span is the smoothing parameter for LOESS (typical values: 0.3-0.8).
|
||||
func (r *MeasurementRepository) Predict(variable string, date time.Time, span float64) (float64, error) {
|
||||
// 1. Get all measurements for the variable
|
||||
measurements, err := r.GetByVariable(variable)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(measurements) == 0 {
|
||||
return 0, errors.New("no measurements found for variable")
|
||||
}
|
||||
|
||||
// 2. Sort by date
|
||||
sort.Slice(measurements, func(i, j int) bool {
|
||||
return measurements[i].Date.Before(measurements[j].Date)
|
||||
})
|
||||
|
||||
// 3. Prepare data for LOESS: x = seconds since epoch, y = value
|
||||
xs := make([]float64, len(measurements))
|
||||
ys := make([]float64, len(measurements))
|
||||
for i, m := range measurements {
|
||||
xs[i] = float64(m.Date.Unix())
|
||||
ys[i] = m.Value
|
||||
}
|
||||
|
||||
// 4. Fit LOESS model (degree 2 is typical, span 0.3-0.8)
|
||||
// LOESS returns a function f(x float64) float64
|
||||
f := fit.LOESS(xs, ys, 2, span)
|
||||
if f == nil {
|
||||
return 0, errors.New("LOESS fitting failed")
|
||||
}
|
||||
|
||||
// 5. Predict for the requested date
|
||||
predX := float64(date.Unix())
|
||||
predY := f(predX)
|
||||
return predY, nil
|
||||
}
|
||||
|
||||
// GetByVariable returns all measurements for a variable.
|
||||
func (r *MeasurementRepository) GetByVariable(variable string) ([]*Measurement, error) {
|
||||
all, err := r.FindAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var filtered []*Measurement
|
||||
for _, m := range all {
|
||||
if m.Variable == variable {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
125
prompt.txt
Normal file
125
prompt.txt
Normal file
|
@ -0,0 +1,125 @@
|
|||
You are an experienced software engineer. I am designing an app to track my
|
||||
workouts. It will be coded 100% in Golang, ideally without any use of CGo. The
|
||||
workouts consist of Sets of exercises.
|
||||
A Set records the time, the name of the exercise performed, the number of Reps,
|
||||
the Weight lifted and a note (string). A Set can either be a planned set or a
|
||||
completed set, so there needs to be a boolean field "Planned" that is set to
|
||||
true for a planned set and false for an actual completed set.
|
||||
One type of Set is called an AMRAP, which stands for "as many reps as
|
||||
possible." In this case, the value of Reps indicates a target, and a boolean
|
||||
AMRAP is set to true.
|
||||
An AMRAP with a target of 1 is an attempted max single.
|
||||
When a Set is stored in the database, the ID field (they key in the BoltDB
|
||||
key-value store) is set to a sortable version of the Time field (RFC3339 plus
|
||||
a unique sortable sequence number).
|
||||
A Workout is a list of pointers to Sets. A Workout is stored in the database
|
||||
as a collection of the database IDs for the Sets planned and actually completed
|
||||
in that workout. A workout should also have a boolean indicating whether the
|
||||
workout has been completed (if false it means this is a planned future
|
||||
workout).
|
||||
When accessing the database, there should be a single function that allows
|
||||
variadic parameters, allowing the user to retrieve a list of Workouts or Sets
|
||||
within a given date range that satisfy the other parameters (e.g. date, date
|
||||
range (if two dates are provided), exercise type, completed or planned or both,
|
||||
attempted max, etc. The retrieval function should be as flexible as possible.
|
||||
I will also need a generic data storage function which I will primarily use to
|
||||
record biometrics over time (e.g. weight, waist circumference, etc.). This can
|
||||
be a simple key-value store with the Variable being a string and the Value
|
||||
being a float64. Each entry will be time-stamped with an ID formed as above
|
||||
(RFC3339 date/time plus a unique sortable sequence number).
|
||||
BoltDB will be the backend database for this project.
|
||||
You are to create a detailed project plan including a breakdown of how this app
|
||||
should be divided into modules, what will be required to create each module
|
||||
(including what libraries should be used), and what algorithms or other
|
||||
software engineering approaches or methodologies should be leveraged to
|
||||
successfully complete this project.
|
||||
|
||||
The next step of the project is to create a graphical user interface. I will
|
||||
use the gioui library. The user needs to be able to enter workout plans,
|
||||
view plans for a given day during a workout, and log actual exercise
|
||||
performances achieved on that day. The user will also need to be able to
|
||||
review future plans and previous exercise results. For example, a user may
|
||||
want to see all deadlift sessions over the past 7 months. Additional functions
|
||||
include the ability to predict maximum exercise performance (e.g. one
|
||||
repetition max) based on the weight lifted and the number of reps achieved
|
||||
in a set. This is useful in reviewing past performance (including trends over
|
||||
time as strength on a given lift goes up or down), or for setting a goal for
|
||||
a particular set (e.g. based on a recent performance, I should be able to
|
||||
do a certain number of reps on the next exercise). Before writing any code,
|
||||
provide a detailed analysis of these requirements and an overview of what
|
||||
user interface screens we should include in the app. Then consider if the
|
||||
existing library provides enough low-level functionality or if we should
|
||||
enhance the library before implementing the graphical user interface.
|
||||
|
||||
I would like to move as much as possible into the human-readable direction,
|
||||
with as little extraneous text as possible. Let me know if you think the
|
||||
following design can work and propose any enhancements you think might be
|
||||
necessary. The following entry indicates that a workout was done on August
|
||||
1, 2025. The workout will have a note "pull" indicating the main types of
|
||||
movements done on that day. The first setgroup is four planned sets of
|
||||
deadlift with 315 pounds, there are four sets in the setgroup and each one
|
||||
is for 2 repretitions. The set group has a Type field that says "top sets"
|
||||
and a Note that says "heavier than last time". The next indented line (if
|
||||
present) indicates the actual sets completed for this SetGroup, here showing
|
||||
that three out of the four planned sets were completed, the first two with
|
||||
two reps, and the last with a single rep. The note field on this setgroup
|
||||
will say "failed last rep". The next setgroup in the plan is
|
||||
another deadlift, this time with 275 pounds for two sets of three. The next
|
||||
indented line shows that the first set was completed with 3 reps as planned,
|
||||
but the weight was changed to 270 pounds for the second set, which was also
|
||||
done with three reps. The next line shows that a chinup with 110 pounds added
|
||||
weight was planned but not completed. The next setgroup is a chinup with 90
|
||||
pounds performed for two sets of three. The first set of three was completed
|
||||
but only two reps were done in the second set. The last setgroup shows dips
|
||||
with 25 pounds to be performed for 5 sets of 8 in an EMOM style (type field
|
||||
set to EMOM with no note). The user completed four sets of 8 but did not do
|
||||
the fifth planned set.
|
||||
|
||||
2025-08-01 (pull)
|
||||
deadlift 315 4x2 (top sets) heavier than last time
|
||||
2-2-1 failed last rep
|
||||
deadlift 275 2x3 (backoff sets)
|
||||
3-270x3
|
||||
chinup 110 1 (top single)
|
||||
chinup 90 2x3 (backoff sets)
|
||||
3-2
|
||||
dip 25 5x8 (EMOM)
|
||||
8-8-8-8
|
||||
|
||||
I have a database that contains information about workouts. My top-level project is accessed from git.wow.st/logbook. The data is stored
|
||||
in the following structs (this is my intermediate representation defined in the git.wow.st/logbook/parser pakage):
|
||||
|
||||
type Workout struct {
|
||||
Date time.Time
|
||||
Type string
|
||||
Note string
|
||||
SetGroups []SetGroup
|
||||
}
|
||||
|
||||
type SetGroup struct {
|
||||
Exercise string
|
||||
Type string
|
||||
Note string
|
||||
PlannedSets []Set
|
||||
ActualSets []Set
|
||||
}
|
||||
|
||||
type Set struct {
|
||||
Weight float64
|
||||
Reps int
|
||||
Note string
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
type Measurement struct {
|
||||
Date time.Time
|
||||
Variable string
|
||||
Value float64
|
||||
Note string
|
||||
}
|
||||
|
||||
I need to add functions to review past workout performance. The important data here will be actual sets contained within a setgroup. Only actual sets should be compared. Sets can be compared if they are found in a SetGroup with the same Exercise and Type field (type is used to distinguish between variations of an exercise, e.g. a convention deadlift vs. a sumo deadlift will both have deadlift in the Exercise field but conventional (or blank) and sumo respectively in the Type field). We have WorkoutRepository which has a method FindSetGroups(match func(*parser.SetGroup) bool) ([]*parser.SetGroup, error). Because of the database storage conventions, the list of SetGroups returned by this function will always be sorted by date from oldest to newest. The Set struct has a Time field which can be used to identify the time and date on which a particlar performance was achieved.
|
||||
|
||||
Before writing any code, sketch out a high-level design for a flexible package to be imported from git.wow.st/logbook/review that allows the user to easly review past workout performances of the same type. For example, the user might want a list of the most recent performances of a given exercise with a given weight or within a range of weights. The user might want to know how many reps they have been able to obtain at a given weight with a given exercise, either the lifetime personal best or the most recent performance with that weight. In the case where multiple performances were attempted on the same date (e.g. the SetGroup calls for 3 sets of the same exercise at the same weight) the user might want to know that they were able to do 8 reps on the first and second sets, but only 7 on the third. The user will also want to be able to estimate their strength at a given rep range. For example, if they have deadlifted 315 pounds for 10 reps, how many should they expect to be able to do with 330 pounds? In order to calculate that number, it may be useful to first convert the original performance (315 pounds for 10 reps) to an estimated one-repetition max, and then use that to estmiate what the user could perform with a different weight. As requested, please provide an overview of a flexible exercise review program based on these requirements and the available data sources. As you work though this exercise, please consider what additional sources of data would be useful and whether any intermediate APIs would be helpful to create a flexible and extensible system.
|
||||
|
||||
You are an expert software engineer. Before writing any code, you carefully consider requirements and use cases and design software architectures based on reusable, maintainable code with highly expressive and flexible interfaces. When coming up with designs, you iterate over them to reduce complexity and increase flexibility and expressiveness. You believe that software interfaces work best when common use cases are easy to read and understand, favoring short function names, using composability and flexible types to help users perform complex tasks as simply as possible with the lowest risk of bugs. This software project is written in the Go programming language.
|
75
review/estimate.go
Normal file
75
review/estimate.go
Normal file
|
@ -0,0 +1,75 @@
|
|||
package review
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gonum.org/v1/gonum/optimize"
|
||||
)
|
||||
|
||||
// PowerLawFunc models weight as a function of reps: w = a * reps^b
|
||||
func PowerLawFunc(a, b, reps float64) float64 {
|
||||
return a * math.Pow(reps, b)
|
||||
}
|
||||
|
||||
// WeightedResiduals computes weighted residuals for curve fitting
|
||||
func WeightedResiduals(params []float64, weight, reps []float64, dates []time.Time, now time.Time, halfLifeDays float64) float64 {
|
||||
a, b := params[0], params[1]
|
||||
var sum float64
|
||||
for i := range weight {
|
||||
// Exponential time decay weighting
|
||||
daysAgo := now.Sub(dates[i]).Hours() / 24
|
||||
weightDecay := math.Exp(-math.Ln2 * daysAgo / halfLifeDays) // Half-life decay
|
||||
predicted := PowerLawFunc(a, b, reps[i])
|
||||
residual := weight[i] - predicted
|
||||
sum += weightDecay * residual * residual
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// FitPowerLaw fits the power law curve with time weighting
|
||||
func FitPowerLaw(weight, reps []float64, dates []time.Time, halfLifeDays float64) (a, b float64) {
|
||||
now := time.Now()
|
||||
// Initial guess: a = max(weight), b = -0.1
|
||||
params := []float64{max(weight), -0.1}
|
||||
problem := optimize.Problem{
|
||||
Func: func(x []float64) float64 {
|
||||
return WeightedResiduals(x, weight, reps, dates, now, halfLifeDays)
|
||||
},
|
||||
}
|
||||
result, err := optimize.Minimize(problem, params, nil, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return result.X[0], result.X[1]
|
||||
}
|
||||
|
||||
// max returns the maximum value in a slice
|
||||
func max(slice []float64) float64 {
|
||||
m := slice[0]
|
||||
for _, v := range slice {
|
||||
if v > m {
|
||||
m = v
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Estimate1RM estimates the current 1RM (reps=1) using fitted parameters
|
||||
func Estimate1RM(a, b float64) float64 {
|
||||
return PowerLawFunc(a, b, 1)
|
||||
}
|
||||
|
||||
// EstimateReps returns the predicted number of reps at a given weight
|
||||
func EstimateReps(a, b, targetWeight float64) float64 {
|
||||
// Avoid division by zero or negative exponent issues
|
||||
if a == 0 || b == 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Pow(targetWeight/a, 1/b)
|
||||
}
|
||||
|
||||
// EstimateMaxWeight returns the predicted max weight for a given number of reps
|
||||
func EstimateMaxWeight(a, b, nReps float64) float64 {
|
||||
return a * math.Pow(nReps, b)
|
||||
}
|
168
review/exercises.go
Normal file
168
review/exercises.go
Normal file
|
@ -0,0 +1,168 @@
|
|||
package review
|
||||
|
||||
type Exercise struct {
|
||||
ID string // Combination of Name and Variation, e.g. "deadlift" or "deadlift:sumo"
|
||||
Name string // Base exercise name, e.g. "deadlift"
|
||||
Variation string // Variation, e.g. "sumo", "high bar", etc.
|
||||
PrimaryMuscles []string
|
||||
SecondaryMuscles []string
|
||||
Bodyweight bool // True if bodyweight is a significant load (e.g. pullup, dip)
|
||||
Estimate1RM func(float64, int) float64
|
||||
}
|
||||
|
||||
// Helper to generate ID from Name and Variation
|
||||
func ExerciseID(name, variation string) string {
|
||||
if variation == "" {
|
||||
return name
|
||||
}
|
||||
return name + ":" + variation
|
||||
}
|
||||
|
||||
func IsBodyweight(ss ...string) bool {
|
||||
if len(ss) == 0 {
|
||||
return false
|
||||
}
|
||||
name := ss[0]
|
||||
var variation string
|
||||
if len(ss) > 1 {
|
||||
variation = ss[1]
|
||||
}
|
||||
return GetExercise(name, variation).Bodyweight
|
||||
}
|
||||
|
||||
func GetEstimate1RM(ss ...string) func(float64, int) float64 {
|
||||
if len(ss) == 0 {
|
||||
return Epley1RM
|
||||
}
|
||||
name := ss[0]
|
||||
var variation string
|
||||
if len(ss) > 1 {
|
||||
variation = ss[1]
|
||||
}
|
||||
ret := GetExercise(name, variation).Estimate1RM
|
||||
if ret == nil {
|
||||
return Epley1RM
|
||||
} else {
|
||||
return ret
|
||||
}
|
||||
}
|
||||
|
||||
var Exercises = []*Exercise{
|
||||
{
|
||||
ID: ExerciseID("deadlift", ""),
|
||||
Name: "deadlift",
|
||||
Variation: "",
|
||||
PrimaryMuscles: []string{"back", "glutes", "hamstrings"},
|
||||
SecondaryMuscles: []string{"forearms", "traps"},
|
||||
Bodyweight: false,
|
||||
Estimate1RM: Epley1RM,
|
||||
},
|
||||
{
|
||||
ID: ExerciseID("deadlift", "sumo"),
|
||||
Name: "deadlift",
|
||||
Variation: "sumo",
|
||||
PrimaryMuscles: []string{"back", "glutes", "hamstrings", "adductors"},
|
||||
SecondaryMuscles: []string{"forearms", "traps"},
|
||||
Bodyweight: false,
|
||||
Estimate1RM: Epley1RM,
|
||||
},
|
||||
{
|
||||
ID: ExerciseID("squat", ""),
|
||||
Name: "squat",
|
||||
Variation: "",
|
||||
PrimaryMuscles: []string{"quadriceps", "glutes"},
|
||||
SecondaryMuscles: []string{"hamstrings", "lower back"},
|
||||
Bodyweight: false,
|
||||
Estimate1RM: Epley1RM,
|
||||
},
|
||||
{
|
||||
ID: ExerciseID("squat", "high bar"),
|
||||
Name: "squat",
|
||||
Variation: "high bar",
|
||||
PrimaryMuscles: []string{"quadriceps", "glutes"},
|
||||
SecondaryMuscles: []string{"hamstrings", "lower back"},
|
||||
Bodyweight: false,
|
||||
Estimate1RM: Epley1RM,
|
||||
},
|
||||
{
|
||||
ID: ExerciseID("hack squat", ""),
|
||||
Name: "hack squat",
|
||||
Variation: "",
|
||||
PrimaryMuscles: []string{"quadriceps", "glutes"},
|
||||
SecondaryMuscles: []string{"hamstrings"},
|
||||
Bodyweight: false,
|
||||
Estimate1RM: Epley1RM,
|
||||
},
|
||||
{
|
||||
ID: ExerciseID("dip", ""),
|
||||
Name: "dip",
|
||||
Variation: "",
|
||||
PrimaryMuscles: []string{"chest", "triceps"},
|
||||
SecondaryMuscles: []string{"shoulders"},
|
||||
Bodyweight: true,
|
||||
Estimate1RM: Epley1RM,
|
||||
},
|
||||
{
|
||||
ID: ExerciseID("chinup", ""),
|
||||
Name: "chinup",
|
||||
Variation: "",
|
||||
PrimaryMuscles: []string{"back", "biceps"},
|
||||
SecondaryMuscles: []string{"forearms"},
|
||||
Bodyweight: true,
|
||||
Estimate1RM: Epley1RM,
|
||||
},
|
||||
{
|
||||
ID: ExerciseID("pullup", ""),
|
||||
Name: "pullup",
|
||||
Variation: "",
|
||||
PrimaryMuscles: []string{"back", "biceps"},
|
||||
SecondaryMuscles: []string{"forearms"},
|
||||
Bodyweight: true,
|
||||
Estimate1RM: Epley1RM,
|
||||
},
|
||||
{
|
||||
ID: ExerciseID("row", "barbell"),
|
||||
Name: "row",
|
||||
Variation: "barbell",
|
||||
PrimaryMuscles: []string{"back", "lats", "rhomboids"},
|
||||
SecondaryMuscles: []string{"biceps", "forearms"},
|
||||
Bodyweight: false,
|
||||
Estimate1RM: Epley1RM,
|
||||
},
|
||||
{
|
||||
ID: ExerciseID("row", "dumbbell"),
|
||||
Name: "row",
|
||||
Variation: "dumbbell",
|
||||
PrimaryMuscles: []string{"back", "lats", "rhomboids"},
|
||||
SecondaryMuscles: []string{"biceps", "forearms"},
|
||||
Bodyweight: false,
|
||||
Estimate1RM: Epley1RM,
|
||||
},
|
||||
{
|
||||
ID: ExerciseID("row", "chest supported"),
|
||||
Name: "row",
|
||||
Variation: "chest supported",
|
||||
PrimaryMuscles: []string{"back", "lats", "rhomboids"},
|
||||
SecondaryMuscles: []string{"biceps", "forearms"},
|
||||
Bodyweight: false,
|
||||
Estimate1RM: Epley1RM,
|
||||
},
|
||||
}
|
||||
|
||||
func GetExercise(ss ...string) *Exercise {
|
||||
if len(ss) == 0 {
|
||||
return &Exercise{}
|
||||
}
|
||||
name := ss[0]
|
||||
var variation string
|
||||
if len(ss) > 1 {
|
||||
variation = ss[1]
|
||||
}
|
||||
for _, e := range(Exercises) {
|
||||
if (e.Name == name && e.Variation == variation) {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return &Exercise{}
|
||||
}
|
||||
|
438
review/main.go
Normal file
438
review/main.go
Normal file
|
@ -0,0 +1,438 @@
|
|||
package review
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.wow.st/gmp/logbook"
|
||||
"git.wow.st/gmp/logbook/parser"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Performance represents one or more actual sets performed together.
|
||||
type Performance struct {
|
||||
Exercise string
|
||||
Type string
|
||||
Weights []float64 // one weight per set
|
||||
Reps []int // one reps count per set
|
||||
Times []time.Time // one timestamp per set
|
||||
Note string
|
||||
Bodyweight float64 // bodyweight at or near these times
|
||||
RIR []int
|
||||
}
|
||||
|
||||
func (p Performance) String() string {
|
||||
if len(p.Weights) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var result []string
|
||||
result = append(result, p.Times[0].UTC().Format("2 Jan 2006") +
|
||||
": " +
|
||||
p.Exercise)
|
||||
if p.Type != "" {
|
||||
result = append(result, "(" + p.Type + ")")
|
||||
}
|
||||
|
||||
result = append(result, fmt.Sprintf("%0.0f", p.Weights[0]))
|
||||
|
||||
currentWeight := p.Weights[0]
|
||||
currentReps := p.Reps[0]
|
||||
count := 1
|
||||
firstMax := 0 // pointer to the first entry with a zero RIR
|
||||
if p.RIR[0] != 0 {
|
||||
firstMax = -1
|
||||
}
|
||||
|
||||
for i := 1; i < len(p.Weights); i++ {
|
||||
if p.Weights[i] == currentWeight && p.Reps[i] == currentReps {
|
||||
count++
|
||||
} else {
|
||||
result = append(result, fmt.Sprintf("%dx%d", count, currentReps))
|
||||
count = 1
|
||||
if p.Weights[i] != currentWeight {
|
||||
result = append(result, fmt.Sprintf("%0.0f", p.Weights[i]))
|
||||
currentWeight = p.Weights[i]
|
||||
}
|
||||
currentReps = p.Reps[i]
|
||||
}
|
||||
if firstMax == -1 && p.RIR[i] == 0 {
|
||||
firstMax = i
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, fmt.Sprintf("%dx%d", count, currentReps))
|
||||
if firstMax != -1 {
|
||||
result = append(result, fmt.Sprintf("(1RM: %0.0f)", Estimated1RM(p)))
|
||||
}
|
||||
return strings.Join(result, " ")
|
||||
}
|
||||
|
||||
func (p Performance) String2() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(p.Times[0].UTC().Format("2 Jan 2006") + ": ")
|
||||
b.WriteString(p.Exercise)
|
||||
if p.Type != "" {
|
||||
b.WriteString(" (" + p.Type + ")")
|
||||
}
|
||||
wt := p.Weights[0]
|
||||
b.WriteString(" " + fmt.Sprintf("%0.0f", wt))
|
||||
b.WriteString(" " + fmt.Sprintf("%d", p.Reps[0]))
|
||||
if len(p.Reps) > 1 {
|
||||
for i,r := range(p.Reps[1:]) {
|
||||
b.WriteString("-")
|
||||
if p.Weights[i+1] != wt {
|
||||
b.WriteString(fmt.Sprintf("%0.0fx",p.Weights[i+1]))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%d", r))
|
||||
}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" (%0.0f)", Estimated1RM(p)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type Performances []Performance
|
||||
|
||||
// PerformanceRepository defines interface to retrieve performances.
|
||||
type PerformanceRepository interface {
|
||||
// FindPerformances returns performances matching the filter, sorted oldest to newest.
|
||||
FindPerformances(filter func(Performance) bool) (Performances, error)
|
||||
}
|
||||
|
||||
// performanceRepo implements PerformanceRepository using WorkoutRepository and MeasurementRepository.
|
||||
type performanceRepo struct {
|
||||
workoutRepo *logbook.WorkoutRepository
|
||||
measurementRepo *logbook.MeasurementRepository
|
||||
}
|
||||
|
||||
// NewPerformanceRepository creates a PerformanceRepository backed by the given repositories.
|
||||
func NewPerformanceRepository(wr *logbook.WorkoutRepository, mr *logbook.MeasurementRepository) PerformanceRepository {
|
||||
return &performanceRepo{
|
||||
workoutRepo: wr,
|
||||
measurementRepo: mr,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *performanceRepo) FindPerformances(filter func(Performance) bool) (Performances, error) {
|
||||
// If filter is nil, accept all SetGroups
|
||||
if filter == nil {
|
||||
filter = func(p Performance) bool { return true }
|
||||
}
|
||||
|
||||
// We create a SetGroup-level filter that accepts any SetGroup
|
||||
// whose Exercise and Type might possibly match the Performance filter.
|
||||
// Because filter works on Performance (which includes weights, reps, etc),
|
||||
// we conservatively accept all SetGroups and filter later.
|
||||
// But if filter is composed of simple Exercise/Type filters, we can optimize.
|
||||
|
||||
// Attempt to extract Exercise and Type filters from the given filter func
|
||||
// (This requires filter composition knowledge; otherwise fallback to accept all)
|
||||
|
||||
// For simplicity, here we pass all SetGroups and filter in Go:
|
||||
setGroups, err := r.workoutRepo.FindSetGroups(func(sg *parser.SetGroup) bool {
|
||||
// Construct a minimal Performance with only Exercise and Type to test filter
|
||||
p := Performance{
|
||||
Exercise: sg.Exercise,
|
||||
Type: sg.Type,
|
||||
}
|
||||
return filter(p)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var performances []Performance
|
||||
|
||||
for _, sg := range setGroups {
|
||||
if len(sg.ActualSets) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
weights := make([]float64, 0, len(sg.ActualSets))
|
||||
reps := make([]int, 0, len(sg.ActualSets))
|
||||
times := make([]time.Time, 0, len(sg.ActualSets))
|
||||
RIR := make([]int, 0, len(sg.ActualSets))
|
||||
|
||||
for _, set := range sg.ActualSets {
|
||||
weights = append(weights, set.Weight)
|
||||
reps = append(reps, set.Reps)
|
||||
times = append(times, set.Time)
|
||||
RIR = append(RIR, set.RIR)
|
||||
}
|
||||
|
||||
earliestTime := times[0]
|
||||
bodyweight, err := r.measurementRepo.GetOrPredict("bodyweight", earliestTime)
|
||||
if err != nil {
|
||||
bodyweight = 0
|
||||
}
|
||||
|
||||
p := Performance{
|
||||
Exercise: sg.Exercise,
|
||||
Type: sg.Type,
|
||||
Weights: weights,
|
||||
Reps: reps,
|
||||
Times: times,
|
||||
Note: sg.Note,
|
||||
Bodyweight: bodyweight,
|
||||
RIR: RIR,
|
||||
}
|
||||
|
||||
if filter(p) {
|
||||
performances = append(performances, p)
|
||||
}
|
||||
}
|
||||
|
||||
return Performances(performances), nil
|
||||
}
|
||||
|
||||
func (ps *Performances) FindPerformances(filter func(Performance) bool) ([]Performance, error) {
|
||||
var performances Performances
|
||||
for _, p := range (*ps) {
|
||||
if filter(p) {
|
||||
performances = append(performances, p)
|
||||
}
|
||||
}
|
||||
return performances, nil
|
||||
}
|
||||
|
||||
//////////////////////////
|
||||
// Filtering Utilities //
|
||||
//////////////////////////
|
||||
|
||||
// FilterByExerciseType returns a filter for a given exercise and type.
|
||||
func FilterByExerciseType(exercise, typ string) func(Performance) bool {
|
||||
return func(p Performance) bool {
|
||||
return p.Exercise == exercise && p.Type == typ
|
||||
}
|
||||
}
|
||||
|
||||
// FilterByWeightRange returns a filter that matches any set weight within [min, max].
|
||||
func FilterByWeightRange(min, max float64) func(Performance) bool {
|
||||
return func(p Performance) bool {
|
||||
for _, w := range p.Weights {
|
||||
if w >= min && w <= max {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// FilterByDateRange returns a filter that matches any set time within [start, end].
|
||||
func FilterByDateRange(start, end time.Time) func(Performance) bool {
|
||||
return func(p Performance) bool {
|
||||
for _, t := range p.Times {
|
||||
if !t.Before(start) && !t.After(end) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// And composes multiple filters with logical AND.
|
||||
func And(filters ...func(Performance) bool) func(Performance) bool {
|
||||
return func(p Performance) bool {
|
||||
for _, f := range filters {
|
||||
if !f(p) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Or composes multiple filters with logical OR.
|
||||
func Or(filters ...func(Performance) bool) func(Performance) bool {
|
||||
return func(p Performance) bool {
|
||||
for _, f := range filters {
|
||||
if f(p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////
|
||||
// Aggregation Functions //
|
||||
//////////////////////////
|
||||
|
||||
// RecentPerformances returns up to n most recent performances matching filter, sorted newest first.
|
||||
func RecentPerformances(repo PerformanceRepository, filter func(Performance) bool, n int) ([]Performance, error) {
|
||||
perfs, err := repo.FindPerformances(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Sort by latest set time descending
|
||||
sort.Slice(perfs, func(i, j int) bool {
|
||||
return latestTime(perfs[i]).After(latestTime(perfs[j]))
|
||||
})
|
||||
|
||||
if n > 0 && len(perfs) > n {
|
||||
perfs = perfs[:n]
|
||||
}
|
||||
return perfs, nil
|
||||
}
|
||||
|
||||
// PersonalBest returns the performance with the highest max estimated 1RM for the given exercise, type, and weight.
|
||||
func PersonalBest(repo PerformanceRepository, exercise, typ string, weight float64) (Performance, error) {
|
||||
filter := And(
|
||||
FilterByExerciseType(exercise, typ),
|
||||
FilterByWeightRange(weight, weight),
|
||||
)
|
||||
|
||||
perfs, err := repo.FindPerformances(filter)
|
||||
if err != nil {
|
||||
return Performance{}, err
|
||||
}
|
||||
if len(perfs) == 0 {
|
||||
return Performance{}, errors.New("no performances found")
|
||||
}
|
||||
|
||||
best := perfs[0]
|
||||
bestMax1RM := Estimated1RM(best)
|
||||
for _, p := range perfs[1:] {
|
||||
if m := Estimated1RM(p); m > bestMax1RM {
|
||||
best = p
|
||||
bestMax1RM = m
|
||||
}
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// RepsDistribution returns a map of reps count to frequency for all sets matching exercise, type, and weight.
|
||||
func RepsDistribution(repo PerformanceRepository, exercise, typ string, weight float64) (map[int]int, error) {
|
||||
filter := And(
|
||||
FilterByExerciseType(exercise, typ),
|
||||
FilterByWeightRange(weight, weight),
|
||||
)
|
||||
|
||||
perfs, err := repo.FindPerformances(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dist := make(map[int]int)
|
||||
for _, p := range perfs {
|
||||
for i, w := range p.Weights {
|
||||
if w == weight {
|
||||
dist[p.Reps[i]]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return dist, nil
|
||||
}
|
||||
|
||||
//////////////////////////
|
||||
// Strength Estimation //
|
||||
//////////////////////////
|
||||
|
||||
// Epley1RM estimates one-rep max using Epley formula: 1RM = weight * (1 + reps/30)
|
||||
func Epley1RM(weight float64, reps int) float64 {
|
||||
if reps <= 0 {
|
||||
return 0
|
||||
}
|
||||
return weight * (1 + float64(reps-1)/30.0)
|
||||
}
|
||||
|
||||
// EstimateRepsAtWeight estimates how many reps can be performed at targetWeight given 1RM.
|
||||
func EstimateRepsAtWeight(oneRM, targetWeight float64) int {
|
||||
if targetWeight <= 0 || oneRM <= 0 {
|
||||
return 0
|
||||
}
|
||||
reps := 30 * (oneRM/targetWeight - 1)
|
||||
if reps < 0 {
|
||||
return 0
|
||||
}
|
||||
return int(reps + 0.5) // round to nearest int
|
||||
}
|
||||
|
||||
// EstimateWeightAtReps estimates max weight for given reps and 1RM.
|
||||
func EstimateWeightAtReps(oneRM float64, reps int) float64 {
|
||||
if reps <= 0 {
|
||||
return 0
|
||||
}
|
||||
return oneRM / (1 + float64(reps)/30.0)
|
||||
}
|
||||
|
||||
//////////////////////////
|
||||
// Sorting and Comparison //
|
||||
//////////////////////////
|
||||
|
||||
// LessFunc defines a comparison function for sorting Performances.
|
||||
type LessFunc func(a, b Performance) bool
|
||||
|
||||
// ByMaxEstimated1RM compares by max estimated 1RM (default), tiebreak by lower bodyweight.
|
||||
func ByMaxEstimated1RM(a, b Performance) bool {
|
||||
max1RM_a := Estimated1RM(a)
|
||||
max1RM_b := Estimated1RM(b)
|
||||
if max1RM_a != max1RM_b {
|
||||
return max1RM_a < max1RM_b
|
||||
}
|
||||
// tie-breaker: lower bodyweight ranks higher
|
||||
return a.Bodyweight > b.Bodyweight
|
||||
}
|
||||
|
||||
// ByTotalVolume compares by total volume (sum weight*reps), tiebreak by lower bodyweight.
|
||||
func ByTotalVolume(a, b Performance) bool {
|
||||
volA := totalVolume(a)
|
||||
volB := totalVolume(b)
|
||||
if volA != volB {
|
||||
return volA < volB
|
||||
}
|
||||
return a.Bodyweight > b.Bodyweight
|
||||
}
|
||||
|
||||
// SortPerformances sorts performances in-place using the provided LessFunc.
|
||||
// If less is nil, uses ByMaxEstimated1RM as default.
|
||||
func SortPerformances(perfs []Performance, less LessFunc) {
|
||||
if less == nil {
|
||||
less = ByMaxEstimated1RM
|
||||
}
|
||||
sort.Slice(perfs, func(i, j int) bool {
|
||||
return less(perfs[i], perfs[j])
|
||||
})
|
||||
}
|
||||
|
||||
//////////////////////////
|
||||
// Internal helpers //
|
||||
//////////////////////////
|
||||
|
||||
func Estimated1RM(p Performance) float64 {
|
||||
isbw := IsBodyweight(p.Exercise, "")
|
||||
Estimate1RM := GetEstimate1RM(p.Exercise, p.Type)
|
||||
var bw float64
|
||||
if isbw {
|
||||
bw = p.Bodyweight
|
||||
}
|
||||
max1RM := bw
|
||||
for i := range p.Weights {
|
||||
|
||||
oneRM := Estimate1RM(p.Weights[i] + bw, p.Reps[i])
|
||||
if oneRM > max1RM {
|
||||
max1RM = oneRM
|
||||
}
|
||||
}
|
||||
return max1RM - bw
|
||||
}
|
||||
|
||||
func totalVolume(p Performance) float64 {
|
||||
vol := 0.0
|
||||
for i := range p.Weights {
|
||||
vol += p.Weights[i] * float64(p.Reps[i])
|
||||
}
|
||||
return vol
|
||||
}
|
||||
|
||||
func latestTime(p Performance) time.Time {
|
||||
latest := p.Times[0]
|
||||
for _, t := range p.Times[1:] {
|
||||
if t.After(latest) {
|
||||
latest = t
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user