Initial commit.

This commit is contained in:
Greg Pomerantz 2025-05-09 14:37:57 -04:00
commit cc0ec72936
16 changed files with 3681 additions and 0 deletions

303
cmd/lb/main.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}